"""Pydantic response models for the demand module."""
from __future__ import annotations

from typing import Optional

from pydantic import BaseModel


# ----------------------------------------------------------------------
# Sales — weekly
# ----------------------------------------------------------------------

class SalesWeeklyRow(BaseModel):
    sku: str
    name: Optional[str] = None
    category: Optional[str] = None
    year: int
    week: int
    year_week: Optional[int] = None
    qty_retail: float = 0
    qty_webshop: float = 0
    qty_wholesale: float = 0
    qty_total: float = 0
    tier: Optional[str] = None
    xyz: Optional[str] = None
    # ERP-sourced promo flag for this (sku, year, week). False when missing —
    # see demand_repo.get_sales_weekly for the join source.
    is_promo: Optional[bool] = None
    # Latest avg_sell_price snapshot from erp_prices, per SKU (not per week).
    avg_price: Optional[float] = None


class SalesFiltersApplied(BaseModel):
    tier:      Optional[list[str]] = None
    xyz:       Optional[list[str]] = None
    category:  Optional[list[str]] = None
    year:      Optional[int] = None
    week_from: Optional[int] = None
    week_to:   Optional[int] = None
    sort_by:   Optional[str]  = None
    sort_dir:  Optional[str]  = None
    page:      Optional[int]  = None
    page_size: Optional[int]  = None


class SalesWeeklyResponse(BaseModel):
    rows: list[SalesWeeklyRow]
    total_count: int
    page: int
    page_size: int
    total_pages: int
    filters_applied: SalesFiltersApplied


# ── Per-SKU weekly sales (rewound Sales Weekly: last 13 weeks) ────────
class SkuWeeklyBuyer(BaseModel):
    buyer: str
    qty: float


class SkuWeeklyWeek(BaseModel):
    year: int
    week: int
    cw_label: str
    qty_retail: float
    qty_webshop: float
    qty_wholesale: float
    qty_other: float
    qty_total: float
    on_promo: bool
    promo_label: Optional[str] = None
    wholesale_buyers: list[SkuWeeklyBuyer] = []


class SkuWeeklySalesResponse(BaseModel):
    sku: str
    name: Optional[str] = None
    found: bool
    weeks: list[SkuWeeklyWeek] = []


# ----------------------------------------------------------------------
# Revenue
# ----------------------------------------------------------------------

class RevenueWeek(BaseModel):
    year: int
    week: int
    qty_retail: float
    qty_webshop: float
    qty_wholesale: float
    qty_total: float
    revenue_retail: float
    revenue_webshop: float
    revenue_wholesale: float
    revenue_total: float
    wow_growth_pct: Optional[float] = None


class ChannelMix(BaseModel):
    retail: float
    webshop: float
    wholesale: float


class RevenueSummary(BaseModel):
    year: int
    weeks: list[RevenueWeek]
    ytd_qty: float
    ytd_revenue: float
    avg_weekly_qty: float
    avg_weekly_revenue: float
    channel_mix: ChannelMix
    qty_mix: ChannelMix
    wow_growth_latest: Optional[float] = None


# ----------------------------------------------------------------------
# Forecast accuracy
# ----------------------------------------------------------------------

class FARow(BaseModel):
    sku: str
    name: Optional[str] = None
    year: int
    week: int
    forecast: float
    actual: float
    fa: float
    fa_signed: float
    bias: float
    hit: bool
    tier: Optional[str] = None
    xyz: Optional[str] = None
    model: Optional[str] = None
    channel_mode: Optional[str] = None


class GroupAgg(BaseModel):
    """Lightweight per-group rollup kept for backward-compat clients.

    FATabResponse.by_tier / by_xyz carries the richer FABreakdown shape;
    callers that still hit /api/demand/forecast-accuracy continue to see
    the original dict<str, GroupAgg> shape.
    """
    fa: float
    bias: float
    hit_rate: float
    n: int


class FASummary(BaseModel):
    """Original (pre-4-tab) FA shape — kept so legacy clients don't break."""
    overall_fa: float
    overall_fa_signed: float
    overall_bias: float
    hit_rate: float
    n: int
    by_tier: dict[str, GroupAgg]
    by_xyz: dict[str, GroupAgg]
    rows: list[FARow]


# ----------------------------------------------------------------------
# Forecast accuracy — 4-tab shapes (matches Streamlit page_forecast_accuracy)
# ----------------------------------------------------------------------

class FAHeadline(BaseModel):
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float
    n_sku_weeks: int
    n_skus: int
    n_weeks: int
    fa_signed_last_week: float = 0.0
    last_week_label: str = ""


class FABreakdown(BaseModel):
    """One row of the tier or XYZ breakdown table."""
    label: str
    n_sku_weeks: int
    n_skus: int
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float


class FAWeek(BaseModel):
    """One point in the weekly accuracy chart."""
    year: int
    week: int
    year_week: int
    fa: float
    fa_signed: float
    bias: float
    forecast: float
    actual: float


class FAMonth(BaseModel):
    """One row of the monthly summary table.

    Month label is rendered server-side via _week_to_month from the (year,
    week) → month mapping used by Streamlit (Monday-based; see app.py
    _week_to_month_name).
    """
    month: str
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float
    forecast: float
    actual: float
    n_sku_weeks: int


class FATopImpactor(BaseModel):
    """One row of the top-N SKUs-hurting-FA table."""
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    xyz: Optional[str] = None
    actual: float
    forecast: float
    abs_error: float
    fa: float
    fa_signed: float
    bias: float
    share_of_total_error_pct: float


class FATabResponse(BaseModel):
    """Shape returned by every /forecast-accuracy/* tab."""
    mode: str  # backtest / live / model_only / kam
    headline: FAHeadline
    by_tier:  list[FABreakdown]
    by_xyz:   list[FABreakdown]
    weekly:   list[FAWeek]
    monthly:  list[FAMonth]
    top_impactors: list[FATopImpactor]
    last_week_impactors: list[FATopImpactor] = []   # top SKUs hurting FA in the latest closed week
    last_week_label: Optional[str] = None
    note: Optional[str] = None  # e.g. "forecast log empty — Live FA needs data"


class FAChannelRow(BaseModel):
    channel: str
    fc_baseline: float
    fc_ontop: float
    fc_total: float
    actual: float
    fa: float
    bias: float


class FAOffenderSource(BaseModel):
    sku: str
    name: Optional[str] = None
    fc_baseline: float
    fc_ontop_vp: float
    fc_ontop_mp: float
    fc_total: float
    act_retail: float
    act_web: float
    act_vp: float
    act_total: float
    abs_error: float


class FABreakdownResponse(BaseModel):
    weeks: list[str] = []
    n_weeks: int = 0
    channel_summary: list[FAChannelRow] = []
    offenders: list[FAOffenderSource] = []
    note: Optional[str] = None


# ----- KAM·CM FA (per-person rollups, different shape) --------------------

class KAMFARow(BaseModel):
    """Per-person rollup row. KAM/CM both flow through the same schema."""
    person: str          # display name from users.full_name or username
    role: Optional[str] = None
    n_sku_weeks: int
    forecast: float
    actual: float
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float


class KAMFAResponse(BaseModel):
    mode: str = "kam"
    headline: FAHeadline
    by_person: list[KAMFARow]
    note: Optional[str] = None


# ----- Buyer-level KAM FA (wholesale only) --------------------------------

class BuyerFASkuRow(BaseModel):
    sku: str
    name: Optional[str] = None
    n_weeks: int
    forecast: float
    actual: float
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float


class BuyerFARow(BaseModel):
    buyer: str
    partner_name: Optional[str] = None
    n_sku_weeks: int
    forecast: float
    actual: float
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float
    by_sku: list[BuyerFASkuRow] = []


class KAMBuyerFAPersonRow(BaseModel):
    person: str
    role: Optional[str] = None
    n_sku_weeks: int
    forecast: float
    actual: float
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float
    by_buyer: list[BuyerFARow] = []


class KAMBuyerFAResponse(BaseModel):
    headline: FAHeadline
    by_person: list[KAMBuyerFAPersonRow]
    unmatched_buyers: list[str] = []
    note: Optional[str] = None


# ----------------------------------------------------------------------
# Demand planning — main planning grid (read-only for now)
# ----------------------------------------------------------------------
#
# The grid is one SKU per row × N forecast horizon weeks across. Each cell
# carries the full forecast decomposition (baseline + on-top VP + on-top MP
# + promo uplift, scaled by planner_factor). DB column names are wholesale/
# retail; we expose them as on_top_vp/on_top_mp to match the planner-facing
# terminology used in Streamlit (VP = veleprodaja, MP = maloprodaja).

class WeekForecast(BaseModel):
    year: int
    week: int
    year_week: int              # year*100 + week (e.g. 202621)
    cw_label: str               # "CW21" for display
    baseline: float = 0
    on_top_vp: float = 0        # forecasts.on_top_wholesale
    on_top_mp: float = 0        # forecasts.on_top_retail
    promo_uplift: float = 0
    planner_factor: float = 1.0
    total: float = 0


class DemandPlanRow(BaseModel):
    sku: str
    name: Optional[str] = None
    category: Optional[str] = None
    tier: Optional[str] = None
    xyz: Optional[str] = None
    weeks: list[WeekForecast]
    # Row totals across the horizon (sum of per-week values). The frontend
    # uses these for the rightmost "Total" column without re-summing.
    total_baseline:     float = 0
    total_on_top_vp:    float = 0
    total_on_top_mp:    float = 0
    total_promo_uplift: float = 0
    total:              float = 0


class CycleInfo(BaseModel):
    """Metadata about the run whose forecasts back this view."""
    cycle_id:     Optional[int] = None
    cycle_year_week: Optional[int] = None
    cycle_status: Optional[str] = None
    run_id:       Optional[int] = None
    run_started_at: Optional[str] = None  # ISO-8601 string
    run_by:       Optional[str] = None
    n_skus:       Optional[int] = None


class OnTopSubmitterRow(BaseModel):
    """One submitter's contribution to the on-top inputs for the current cycle."""
    person:        str
    role:          Optional[str] = None
    channel:       Optional[str] = None        # wholesale / retail / etc.
    qty_total:     float = 0
    n_sku_weeks:   int = 0
    last_submitted_at: Optional[str] = None    # ISO-8601 string


class OnTopSummary(BaseModel):
    vp_total: float = 0       # sum of wholesale on-top
    mp_total: float = 0       # sum of retail on-top
    n_submitters: int = 0
    by_submitter: list[OnTopSubmitterRow] = []


class DemandPlanResponse(BaseModel):
    rows: list[DemandPlanRow]
    horizon: list[str]        # ordered CW labels covering all weeks in the response
    cycle_info: Optional[CycleInfo] = None
    on_top_summary: OnTopSummary
    # Headline totals across all rows/weeks — populated even when rows is empty
    # so the summary bar can render zeros without extra null-checks.
    grand_total_baseline:     float = 0
    grand_total_on_top_vp:    float = 0
    grand_total_on_top_mp:    float = 0
    grand_total_promo_uplift: float = 0
    grand_total:              float = 0
    note: Optional[str] = None


# ----------------------------------------------------------------------
# Demand Planning page (Streamlit page_demand_planning parity)
#
# Single-SKU-or-aggregate VIEWER with chart + numbers table + factor editor.
# Fundamentally different shape from DemandPlanResponse above (all-SKU pivot).
# ----------------------------------------------------------------------

class PlanningViewHistoricalWeek(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    qty_total: float = 0
    is_promo: bool = False


class PlanningViewForecastWeek(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    baseline: float = 0            # forecasts.baseline (model output)
    planner_factor: float = 1.0
    stat_adjusted: float = 0       # baseline × planner_factor
    on_top_vp: float = 0           # forecasts.on_top_wholesale
    on_top_mp: float = 0           # forecasts.on_top_retail
    promo_uplift: float = 0
    total: float = 0               # stat_adjusted + on_top_vp + on_top_mp + promo_uplift


class PlanningViewSelectedSku(BaseModel):
    sku: str
    name: Optional[str] = None
    category: Optional[str] = None
    oznaka: Optional[str] = None   # tier
    xyz: Optional[str] = None


class PlanningViewSkuOption(BaseModel):
    sku: str
    name: Optional[str] = None


class PlanningViewResponse(BaseModel):
    display_title: str             # "POL12345 (Product Name)" or "All SKUs (488 SKUs)"
    caption: Optional[str] = None  # "category · oznaka" for single SKU
    is_single_sku: bool
    n_skus: int
    sku_options: list[PlanningViewSkuOption] = []
    historical: list[PlanningViewHistoricalWeek] = []
    forecast: list[PlanningViewForecastWeek] = []
    selected: Optional[PlanningViewSelectedSku] = None
    cycle_info: Optional[CycleInfo] = None
    note: Optional[str] = None


class PlannerFactorUpdate(BaseModel):
    cw_label: str                  # "CW21"
    factor: float                  # 0..5


class SavePlannerFactorsRequest(BaseModel):
    sku: str
    factors: list[PlannerFactorUpdate]


# ----------------------------------------------------------------------
# Revenue forecast page (Streamlit page_revenue() parity)
#
# Forward-looking revenue/RUC view. Reads from forecasts × erp_prices
# (or erp_transactions-derived RUC rates for the margin view). Distinct
# from the historical "Sales History" page which uses past actuals only.
# ----------------------------------------------------------------------

class RevenueForecastWeekPoint(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    month_label: str             # "May 2026"
    # Monday-of-week as ISO date string ("2026-05-25"). Used as the
    # secondary X-axis label so planners see actual dates next to CW labels.
    date_from: str
    is_past: bool
    actual: Optional[float] = None
    forecast: Optional[float] = None
    vp_ontop: Optional[float] = None
    mp_ontop: Optional[float] = None
    # Annual target distributed across weeks. Only populated when source="all"
    # (the plan workbook doesn't split VP/MP) and a category mapping exists.
    plan: Optional[float] = None


class RevenueForecastCategoryRow(BaseModel):
    category: str
    baseline: float = 0
    vp_ontop: float = 0
    mp_ontop: float = 0
    shown_total: float = 0


class RevenueForecastMonth(BaseModel):
    label: str                   # "May 2026"
    cws:   list[str]


class RevenueForecastGrossUp(BaseModel):
    applied: bool = False
    ratio: float = 1.0
    nonplan_share_pct: float = 0.0


class RevenueForecastResponse(BaseModel):
    view: str                                # "revenue" or "ruc"
    source: str                              # "all" / "vp" / "mp"
    unit: str                                # "Revenue" or "RUC"
    unit_symbol: str = "€"
    has_ruc_data: bool
    can_grossup: bool
    categories: list[str] = []
    months: list[RevenueForecastMonth] = []
    chart: list[RevenueForecastWeekPoint] = []
    category_breakdown: list[RevenueForecastCategoryRow] = []
    total_value: float = 0
    avg_weekly: float = 0
    margin_pct: Optional[float] = None
    gross_up: RevenueForecastGrossUp = RevenueForecastGrossUp()
    # Sum of plan across the same weeks as ``total_value`` (for the headline).
    # Null when no plan is available for the active filters (e.g. VP/MP only).
    plan_total: Optional[float] = None
    note: Optional[str] = None


# ----------------------------------------------------------------------
# SKU list
# ----------------------------------------------------------------------

class SkuInfo(BaseModel):
    sku: str
    name: Optional[str] = None
    category: Optional[str] = None
    subcategory: Optional[str] = None
    tier: Optional[str] = None
    total_xyz: Optional[str] = None
    total_cv: Optional[float] = None
    ws_xyz: Optional[str] = None
    ws_cv: Optional[float] = None
    ws_nz_weeks: Optional[int] = None
    total_nz_weeks: Optional[int] = None
    ws_share_26w: Optional[float] = None
    vpc: Optional[float] = None


# ----------------------------------------------------------------------
# SKU detail — full profile
# ----------------------------------------------------------------------

class SkuSalesPoint(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    qty_retail: float
    qty_webshop: float
    qty_wholesale: float
    qty_total: float
    is_promo: bool = False


class SkuForecastPoint(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    baseline: float
    on_top_vp: float
    on_top_mp: float
    promo_uplift: float
    planner_factor: float
    total: float


class SkuPromoPeriod(BaseModel):
    """One contiguous run of promo weeks. Streamlit reports promo activity in
    periods rather than individual weeks for readability."""
    year_week_from: int
    year_week_to:   int
    cw_label_from: str
    cw_label_to:   str
    n_weeks: int
    promo_types: Optional[str] = None  # joined unique values across the run


class SkuStock(BaseModel):
    """Aggregated across stores for the SKU."""
    stock_qty:      float = 0
    purchase_value: float = 0
    retail_value:   float = 0
    minimum_total:  Optional[float] = None
    optimum_total:  Optional[float] = None
    n_stores:       int = 0
    updated_at:     Optional[str] = None


class SkuPricing(BaseModel):
    avg_sell_price:     Optional[float] = None
    normal_retail_ppp:  Optional[float] = None
    normal_webshop_ppp: Optional[float] = None
    vpc:                Optional[float] = None       # wholesale price (sku_planning)
    # Cost cascade: prefer erp_costs.cost_price (standard ERP cost), then
    # realized purchase_value/quantity from erp_transactions (last 13w),
    # then npd_products.cost_price (planned cost from the NPD Excel). The
    # cost_source label tells the UI which one is showing.
    cost_price:         Optional[float] = None
    cost_source:        Optional[str]   = None        # 'erp_standard' | 'realized' | 'npd_planned'
    # RUC — realized per-unit margin from erp_transactions.ruc_eur / quantity
    # over the last N weeks (see `ruc_window`). NULL when no recent sales.
    # `ruc` is the blended (volume-weighted across all channels) figure used
    # on the headline metric card; per-channel breakdown is for tooltips.
    ruc:                Optional[float] = None       # blended €/unit (realized)
    ruc_retail:         Optional[float] = None       # retail + webshop combined
    ruc_wholesale:      Optional[float] = None
    ruc_units:          Optional[int]   = None       # units behind the blended RUC
    ruc_source:         Optional[str]   = None       # 'realized' | 'erp_estimate' | None
    ruc_window:         Optional[str]   = None       # human label, e.g. 'last 13w'
    cost_valid_from:    Optional[str]   = None


class SkuDetailResponse(BaseModel):
    sku:          str
    name:         Optional[str] = None
    category:     Optional[str] = None
    subcategory:  Optional[str] = None
    brand:        Optional[str] = None
    flavor_color: Optional[str] = None
    size:         Optional[str] = None
    supplier:     Optional[str] = None
    family:       Optional[str] = None
    active:       bool = True

    tier:        Optional[str] = None
    xyz:         Optional[str] = None
    total_cv:    Optional[float] = None
    ws_xyz:      Optional[str] = None
    ws_cv:       Optional[float] = None
    ws_share_26w: Optional[float] = None

    pricing:     SkuPricing
    stock:       SkuStock
    coverage_weeks: Optional[float] = None  # stock / 4-week avg qty
    avg_weekly_qty_4w: float = 0

    sales_26w:    list[SkuSalesPoint]
    forecast_13w: list[SkuForecastPoint]
    promo_periods: list[SkuPromoPeriod]


# ----------------------------------------------------------------------
# Watchlist
# ----------------------------------------------------------------------

class WatchlistRow(BaseModel):
    rank: int
    sku: str
    name: Optional[str] = None
    category: Optional[str] = None
    tier: Optional[str] = None
    xyz: Optional[str] = None
    last_4w_avg:       float = 0
    forecast_next_4w:  float = 0
    fa_last_4w:        Optional[float] = None     # backtest-derived if available
    stock_qty:         float = 0
    coverage_weeks:    Optional[float] = None
    revenue_last_4w:   float = 0
    # Flags drive frontend color coding:
    #   low_coverage  → red row    (coverage < 2 weeks AND last_4w_avg > 0)
    #   low_fa        → amber row  (fa_last_4w < 50)
    #   stockout_risk → red row    (stock_qty <= 0 AND last_4w_avg > 0)
    low_coverage: bool = False
    low_fa: bool = False
    stockout_risk: bool = False


class WatchlistResponse(BaseModel):
    sort_by: str
    rows: list[WatchlistRow]
    n_total_candidates: int
    note: Optional[str] = None


# ----------------------------------------------------------------------
# Consensus snapshots — frozen plan snapshots used in S&OP review
# ----------------------------------------------------------------------

class ConsensusSummary(BaseModel):
    """One row in the snapshot list — light enough to power a dropdown."""
    id: int
    label: Optional[str] = None
    cycle_id: Optional[int] = None
    cycle_year_week: Optional[int] = None
    n_skus: Optional[int] = None
    total_rev: Optional[float] = None
    created_at: str   # ISO-8601


class ConsensusDetail(BaseModel):
    """Full snapshot for the detail view. `rows` carries whatever shape the
    snapshot was frozen with — we don't tighten it because legacy snapshots
    may have different columns than future ones."""
    id: int
    label: Optional[str] = None
    cycle_id: Optional[int] = None
    cycle_year_week: Optional[int] = None
    n_skus: Optional[int] = None
    total_rev: Optional[float] = None
    created_at: str
    rows:           list[dict] = []
    wholesale_inputs: list[dict] = []
    retail_inputs:    list[dict] = []


class ConsensusDiffRow(BaseModel):
    sku: str
    name: Optional[str] = None
    a_total: Optional[float] = None
    b_total: Optional[float] = None
    delta:   Optional[float] = None
    delta_pct: Optional[float] = None
    change:  str  # "added" / "removed" / "changed" / "unchanged"


class ConsensusDiff(BaseModel):
    a: ConsensusSummary
    b: ConsensusSummary
    a_total_rev: float = 0
    b_total_rev: float = 0
    delta_rev:   float = 0
    n_added:    int = 0
    n_removed:  int = 0
    n_changed:  int = 0
    n_unchanged: int = 0
    rows: list[ConsensusDiffRow]


class ConsensusListResponse(BaseModel):
    snapshots: list[ConsensusSummary]
    note: Optional[str] = None


# ----------------------------------------------------------------------
# S&OP meeting package — agenda + exceptions + KPIs
# ----------------------------------------------------------------------

class SopExceptionSkuRow(BaseModel):
    """One SKU surfaced by an exception scanner. Compact intentionally — the
    full SKU profile is reachable via the row's click target."""
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    category: Optional[str] = None
    metric: float           # context-dependent: coverage_weeks, abs_error, ratio, ...
    metric_label: str       # human-readable units, e.g. "1.2 weeks", "abs err 4,200"
    detail: Optional[str] = None


class SopFaMissRow(BaseModel):
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    year: int
    week: int
    forecast: float
    actual: float
    abs_error: float
    fa: float
    bias: float


class SopOnTopAnomaly(BaseModel):
    sku: str
    name: Optional[str] = None
    person: Optional[str] = None
    channel: Optional[str] = None
    year: int
    week: int
    qty: float
    avg_qty_for_sku: float
    ratio: float   # qty / avg, > 3 to qualify


class SopPromoOverlap(BaseModel):
    year: int
    week: int
    year_week: int
    cw_label: str
    n_skus: int
    skus: list[str]   # capped at 10 in service for UI sanity


class SopFaByTier(BaseModel):
    tier: str
    fa: float
    fa_signed: float
    bias: float
    hit_rate: float
    n_sku_weeks: int


class SopOnTopPerson(BaseModel):
    person: str
    role: Optional[str] = None
    qty_total: float
    n_sku_weeks: int
    last_submitted_at: Optional[str] = None


class SopOnTopStatus(BaseModel):
    n_submitters: int
    by_person: list[SopOnTopPerson]


class SopUpcomingPromo(BaseModel):
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    year: int
    week: int
    cw_label: str
    promo_types: Optional[str] = None
    expected_volume: float   # baseline forecast for that week if available, else avg sales


class SopExceptions(BaseModel):
    low_coverage:   list[SopExceptionSkuRow]
    biggest_fa_misses: list[SopFaMissRow]
    on_top_anomalies:  list[SopOnTopAnomaly]
    promo_overlaps:    list[SopPromoOverlap]


class SopKpis(BaseModel):
    fa_by_tier: list[SopFaByTier]
    on_top_status: SopOnTopStatus
    overall_fa: float = 0
    overall_hit_rate: float = 0
    n_sku_weeks_scoring: int = 0


class SopMeetingPackage(BaseModel):
    cycle_info: Optional[CycleInfo] = None
    latest_data_week: Optional[int] = None   # year*100+week from v_sales_weekly_full
    exceptions: SopExceptions
    kpis: SopKpis
    upcoming_promos: list[SopUpcomingPromo]
    suggested_actions: list[str]
    notes: list[str] = []


# ----------------------------------------------------------------------
# Upload / forecast endpoints (operational, not analytical)
# ----------------------------------------------------------------------

class UploadResponse(BaseModel):
    rows_added: int
    n_files: int
    new_products: int
    new_partners: int
    new_stores: int
    warnings: list[str] = []
    view_refreshed: bool
    date_range: Optional[dict] = None


class StockUploadFileResult(BaseModel):
    country: str
    filename: str
    rows_parsed: int
    wh_skus: int
    store_skus: int
    wh_units: float = 0
    store_units: float = 0
    error: Optional[str] = None


class StockUploadResponse(BaseModel):
    n_files: int
    files: list[StockUploadFileResult]
    csvs_written: list[str]
    rows_inserted_to_db: int
    db_error: Optional[str] = None
    warnings: list[str] = []


class RunForecastRequest(BaseModel):
    run_type: str = "baseline"           # 'baseline' or 'with_ontop'
    run_by_id: Optional[int] = None


class RunForecastResponse(BaseModel):
    """Always returned with HTTP 200. success=False on pre-flight or engine
    failure — caller inspects `error` + `stdout_tail` for diagnosis."""
    success: bool
    run_id: Optional[int] = None
    cycle_id: Optional[int] = None
    sku_count: int = 0
    rows_inserted: int = 0
    factor_history_inserted: int = 0
    duration_seconds: int = 0
    warnings: list[str] = []
    error: Optional[str] = None
    stdout_tail: Optional[str] = None


class ForecastPrereqsResponse(BaseModel):
    ok: bool
    missing: list[str] = []
    planning_book_exists: bool
    last_plan_exists: bool
    sales_clean_exists: bool
    engine_exists: bool
    data_dir: str
    db_available: bool = False


class ForecastRunRow(BaseModel):
    run_id: int
    run_type: Optional[str] = None
    n_skus: int
    year_week: int
    started_at: Optional[str] = None
    run_by: Optional[str] = None


# ── KAM / CM input schemas ─────────────────────────────────────────────────

class InputTemplateRow(BaseModel):
    sku: str
    product_id: int
    name: str
    tier: str
    category: str
    buyer: Optional[str] = None
    baseline: dict[str, float]        # {"CW21": 2812.9, ...}
    weeks: dict[str, float]           # pre-filled from previous submission


class InputTemplateBuyerGroup(BaseModel):
    buyer: Optional[str] = None
    rows: list[InputTemplateRow]


class InputTemplateResponse(BaseModel):
    user_id: int
    user_name: str
    role: str
    channel: str
    cycle_id: Optional[int] = None
    horizon: list[str]                # ["CW21", "CW22", ..., "CW33"]
    horizon_year_weeks: list[int]     # [202621, 202622, ..., 202633]
    groups: list[InputTemplateBuyerGroup]
    deadline: str                     # e.g. "Monday 17:00"


class SaveOnTopItem(BaseModel):
    product_id: int
    year_week: int                    # YYYYWW integer, e.g. 202621
    qty: float
    buyer: Optional[str] = None


class SaveInputsRequest(BaseModel):
    inputs: list[SaveOnTopItem]


class SaveInputsResponse(BaseModel):
    rows_saved: int
    skus_affected: int
    warnings: list[str] = []


# ─── KAM/CM wizard v2 (audit + lock window) ──────────────────────────────

class InputUserRow(BaseModel):
    """One row in the step-1 identity dropdown."""
    user_id: int
    display_name: str
    role: str                                  # VP | MP
    channel: Optional[str] = None


class SaveInputsV2Item(BaseModel):
    product_id: int
    year_week:  int
    qty:        float


class SaveInputsV2Request(BaseModel):
    """Audit-aware save body: one user × one buyer, all weeks/SKUs.
    `acting_as_id` is set when admin saves on behalf of someone else."""
    user_id:       int
    buyer:         Optional[str] = None
    acting_as_id:  Optional[int] = None
    inputs:        list[SaveInputsV2Item]


class SaveInputsV2Response(BaseModel):
    rows_saved:      int
    skus_affected:   int
    audit_rows:      int = 0
    lock_cutoff_yw:  Optional[int] = None
    warnings:        list[str] = []


class WholesaleInputRow(BaseModel):
    """One listed SKU in the wholesale buyer-listings input grid. `reg` and
    `on_top` hold the existing per-week split so the selector can switch
    without a refetch."""
    sku: str
    product_id: int
    name: str
    tier: Optional[str] = None
    rank: Optional[str] = None
    category: str = ""
    baseline: dict[str, float] = {}
    reg: dict[str, float] = {}
    on_top: dict[str, float] = {}


class WholesaleInputTemplate(BaseModel):
    user_id: int
    user_name: str
    kam: str
    buyer: str
    cycle_id: Optional[int] = None
    horizon: list[str]
    horizon_year_weeks: list[int]
    rows: list[WholesaleInputRow]
    deadline: str


class SaveWholesaleInputRequest(BaseModel):
    """Save one portion of the buyer-listings grid. `portion` ∈ {reg, on_top}."""
    user_id:      int
    buyer:        str
    portion:      str
    acting_as_id: Optional[int] = None
    inputs:       list[SaveInputsV2Item]


class SaveWholesaleInputResponse(BaseModel):
    rows_saved:       int
    audit_rows:       int = 0
    recomputed_cells: int = 0
    warnings:         list[str] = []


class KamUploadSheet(BaseModel):
    sheet: str
    buyer_label: str
    n_skus: int
    suggested_buyer: Optional[str] = None
    is_new: bool = False


class KamUploadParseResponse(BaseModel):
    kam_name: Optional[str] = None
    file_type: Optional[str] = None          # VP | MP
    selected_kam: str
    kam_match: bool = True
    cw_labels: list[str] = []
    sheets: list[KamUploadSheet] = []
    existing_buyers: list[str] = []


class KamUploadSkipped(BaseModel):
    sku: str
    year_week: int
    kind: str                                # added | removed


class KamUploadSheetResult(BaseModel):
    sheet: str
    buyer: str
    added: int = 0
    removed: int = 0
    changed: int = 0
    unchanged: int = 0
    skipped_locked: list[KamUploadSkipped] = []
    unmatched_skus: list[str] = []
    n_unmatched: int = 0


class KamUploadApplyResponse(BaseModel):
    file_type: Optional[str] = None
    kam: str
    dry_run: bool = False
    sheets: list[KamUploadSheetResult] = []
    totals: dict[str, int] = {}
    recomputed_cells: int = 0


class BuyerListingUploadResponse(BaseModel):
    """Result of parsing (and optionally committing) a buyer's listed
    assortment Excel into wholesale_listings."""
    kam: str
    buyer: str
    n_parsed: int
    n_matched: int
    n_unmatched: int
    unmatched_skus: list[str] = []
    n_ranked: int = 0
    committed: bool = False


class OnTopChangeRow(BaseModel):
    id: int
    on_top_input_id: Optional[int] = None
    product_id: int
    sku: Optional[str] = None
    product_name: Optional[str] = None
    year_week: int
    buyer: Optional[str] = None
    channel: Optional[str] = None
    changed_by_id: Optional[int] = None
    changed_by_name: Optional[str] = None
    acting_as_id:   Optional[int] = None
    acting_as_name: Optional[str] = None
    changed_at: str
    change_type: str   # insert | update | delete | locked_qty_change | move
    old_qty: Optional[float] = None
    new_qty: Optional[float] = None
    # Set only when change_type == 'move': the week the qty came from / went to.
    from_week: Optional[int] = None
    to_week: Optional[int] = None
    reason: Optional[str] = None


class OnTopChangesResponse(BaseModel):
    rows: list[OnTopChangeRow]


class SubmissionStatusRow(BaseModel):
    user_id: int
    display_name: str
    role: str
    channel: Optional[str] = None
    submitted: bool
    submitted_at: Optional[str] = None
    total_qty: float = 0.0
    sku_count: int = 0


class SubmissionStatusResponse(BaseModel):
    cycle_id: Optional[int] = None
    cycle_week: Optional[int] = None
    deadline: str
    users: list[SubmissionStatusRow]


# ── Demand Review > KAM Commit-vs-Pull Control ─────────────────────────
class KamControlCoverageRow(BaseModel):
    buyer: str
    partner_id: Optional[int] = None
    partner_name: Optional[str] = None
    matched: bool
    n_commits: int = 0
    total_commit_qty: float = 0.0


class KamControlCoverage(BaseModel):
    n_total: int
    n_matched: int
    n_unmatched: int
    matched: list[str] = []
    unmatched: list[str] = []
    rows: list[KamControlCoverageRow] = []


class KamControlRow(BaseModel):
    person: str
    buyer: str
    partner_id: Optional[int] = None
    partner_name: Optional[str] = None
    sku: str
    sku_name: Optional[str] = None
    tier: Optional[str] = None
    year: int
    week: int
    commit_qty: float
    baseline_qty: float
    actual_qty: float
    incremental_qty: float
    is_first_buy: bool
    is_unmatched: bool
    # One of: FIRST_BUY_HIT, FIRST_BUY_MISS, INCREMENTAL_HIT,
    # INCREMENTAL_PARTIAL, INCREMENTAL_MISS, UNMATCHED
    classification: str


class KamControlByKamRow(BaseModel):
    person: str
    n_commits: int
    n_hits: int
    n_partial: int
    n_miss: int
    n_unmatched: int
    commit_qty: float
    actual_qty: float
    baseline_qty: float
    hit_rate: float


class KamControlSummary(BaseModel):
    n_commits: int = 0
    n_first_buy: int = 0
    n_incremental: int = 0
    n_unmatched: int = 0
    n_first_buy_hits: int = 0
    n_first_buy_miss: int = 0
    n_incremental_hits: int = 0
    n_incremental_partial: int = 0
    n_incremental_miss: int = 0
    total_commit_qty: float = 0.0
    total_actual_qty: float = 0.0
    total_baseline_qty: float = 0.0
    incremental_realized_qty: float = 0.0
    incremental_committed_qty: float = 0.0
    first_buy_hit_rate: float = 0.0
    incremental_hit_rate: float = 0.0
    incremental_lift_realized_pct: float = 0.0


class KamControlBaselineWindow(BaseModel):
    weeks: int
    start: Optional[str] = None
    end: Optional[str] = None


class KamControlWindow(BaseModel):
    n_weeks: int
    weeks: list[str] = []
    year_week_keys: list[int] = []
    baseline_window: KamControlBaselineWindow
    hit_threshold_ratio: float


class KamControlResponse(BaseModel):
    window: KamControlWindow
    coverage: KamControlCoverage
    summary: KamControlSummary
    by_kam: list[KamControlByKamRow] = []
    rows: list[KamControlRow] = []


# ── Wholesale Review > Buyer Planner ──────────────────────────────────
class BuyerNavItem(BaseModel):
    buyer: str
    buyer_lc: str
    partner_id: Optional[int] = None
    partner_name: Optional[str] = None
    matched: bool
    n_weeks: int
    total_qty: float


class KamNavItem(BaseModel):
    person: str
    submitted_by_id: Optional[int] = None
    buyers: list[BuyerNavItem] = []


class BuyerPlannerCell(BaseModel):
    year_week: int
    qty: float
    oos_risk: bool
    is_future: bool


class BuyerPlannerRow(BaseModel):
    product_id: int
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    cells: dict[str, BuyerPlannerCell] = {}
    total_qty: float
    any_oos: bool


class BuyerPlannerGrid(BaseModel):
    weeks: list[str] = []
    year_weeks: list[int] = []
    rows: list[BuyerPlannerRow] = []
    n_oos_cells: int = 0


class BuyerPlannerSelected(BaseModel):
    kam: Optional[str] = None
    buyer: Optional[str] = None


class BuyerPlannerResponse(BaseModel):
    kams: list[KamNavItem] = []
    selected: Optional[BuyerPlannerSelected] = None
    grid: Optional[BuyerPlannerGrid] = None


class BuyerCellEditRequest(BaseModel):
    product_id: int
    buyer: str
    year_week: int                      # current week of the cell
    new_quantity: Optional[float] = None   # None = keep current (pure move)
    new_year_week: Optional[int] = None    # None = keep week (pure qty edit)


class BuyerCellEditResponse(BaseModel):
    product_id: int
    buyer: str
    from_year_week: int
    to_year_week: int
    new_quantity: float
    affected_weeks: list[int]


class BuyerCellsBatchRequest(BaseModel):
    edits: list[BuyerCellEditRequest]


class BuyerCellsBatchResponse(BaseModel):
    n_applied: int
    affected_weeks: list[int]


class PartnerSearchRow(BaseModel):
    id: int
    name: str
    n_txn: int


class BuyerAliasUpsertRequest(BaseModel):
    buyer_lc: str
    partner_id: Optional[int] = None     # None = explicitly unmap


class BuyerAliasUpsertResponse(BaseModel):
    buyer_lc: str
    partner_id: Optional[int] = None
    partner_name: Optional[str] = None


# ── Buyer commit accuracy (committed lift vs actual increment) ────────
class BuyerFAWeek(BaseModel):
    year_week: int
    cw_label: str
    committed: float
    baseline: Optional[float] = None
    actual: Optional[float] = None
    increment: Optional[float] = None
    fa_pct: Optional[float] = None
    is_future: bool
    scored: bool


class BuyerFAArticle(BaseModel):
    product_id: int
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    baseline_rate: float
    committed: float
    actual: float
    baseline: float
    increment: float
    fa_pct: Optional[float] = None
    n_weeks_scored: int
    weeks: list[BuyerFAWeek] = []


class BuyerFASummary(BaseModel):
    committed: float = 0
    scored_committed: float = 0
    baseline: float = 0
    actual: float = 0
    increment: float = 0
    fa_pct: Optional[float] = None
    n_articles: int = 0
    n_weeks_scored: int = 0


class BuyerFASelected(BaseModel):
    kam: Optional[str] = None
    buyer: Optional[str] = None
    partner_id: Optional[int] = None
    partner_name: Optional[str] = None
    matched: bool = False
    # Cadence in the trailing-13w pre-push window — context so the planner can
    # read the per-week baseline correctly (biweekly buyer at 100/wk ≈ 200/order).
    weeks_pulled: Optional[int] = None       # distinct ISO weeks the buyer pulled
    typical_order_qty: Optional[float] = None  # avg units on weeks they did pull
    cadence_label: Optional[str] = None        # weekly / biweekly / monthly / sporadic / no history
    baseline_weeks: Optional[int] = None       # denominator (13 today)


class BuyerFABaselineWindow(BaseModel):
    start: Optional[str] = None
    end: Optional[str] = None


class BuyerFAResponse(BaseModel):
    kams: list[KamNavItem] = []
    selected: Optional[BuyerFASelected] = None
    summary: Optional[BuyerFASummary] = None
    articles: list[BuyerFAArticle] = []
    baseline_weeks: int = 13
    baseline_window: BuyerFABaselineWindow = BuyerFABaselineWindow()


# ── Availability (wholesale) — available-to-promise per buyer ─────────
class WholesaleAvailBuyer(BaseModel):
    buyer: str
    buyer_lc: str
    n_skus: int
    n_unmatched: int


class WholesaleAvailKam(BaseModel):
    kam: str
    buyers: list[WholesaleAvailBuyer] = []


class WholesaleAvailCell(BaseModel):
    year_week: int
    atp: float            # available-to-promise this buyer this week
    projection: float     # buyer's own on-top projection that week
    gap: bool             # True = can't supply (ATP <= 0, or < projection)


class WholesaleAvailRow(BaseModel):
    product_id: Optional[int] = None
    sku: str
    name: Optional[str] = None
    tier: Optional[str] = None
    rank: Optional[str] = None
    wh_stock: float
    any_gap: bool
    cells: dict[str, WholesaleAvailCell] = {}
    # summary view (one row per SKU)
    has_on_top: bool = False
    has_reg_inc: bool = False
    n_gap_weeks: int = 0
    first_gap_cw: Optional[str] = None
    first_gap_yw: Optional[int] = None
    recovery_cw: Optional[str] = None
    recovery_yw: Optional[int] = None
    shortfall_units: float = 0.0
    on_top_units: float = 0.0
    reg_units: float = 0.0
    buyers: list[str] = []


class WholesaleAvailSelected(BaseModel):
    kam: str
    buyer: str
    n_listed: int
    # When 'All buyers' is selected, n_buyers ≥ 1 and all_mode=True; the per-row
    # projection is summed across every buyer of this KAM.
    n_buyers: int = 1
    all_mode: bool = False


class WholesaleAvailResponse(BaseModel):
    kams: list[WholesaleAvailKam] = []
    selected: Optional[WholesaleAvailSelected] = None
    weeks: list[str] = []
    year_weeks: list[int] = []
    rows: list[WholesaleAvailRow] = []
    n_gap_cells: int = 0


# ── Demand Review — consensus-forecast sign-off page ─────────────────
class DemandReviewCycle(BaseModel):
    id: Optional[int] = None
    label: str
    year: int
    week: int
    status: Optional[str] = None
    started_at: Optional[str] = None


class DemandReviewHorizon(BaseModel):
    weeks: list[str] = []
    year_week_keys: list[int] = []


class DemandReviewDecompositionRow(BaseModel):
    year: int
    week: int
    cw_label: str
    baseline_qty: float = 0.0
    baseline_eur: float = 0.0
    planner_lift_qty: float = 0.0
    planner_lift_eur: float = 0.0
    on_top_ws_qty: float = 0.0
    on_top_ws_eur: float = 0.0
    on_top_mp_qty: float = 0.0
    on_top_mp_eur: float = 0.0
    promo_uplift_qty: float = 0.0
    promo_uplift_eur: float = 0.0
    consensus_qty: float = 0.0
    consensus_eur: float = 0.0


class DemandReviewDecomposition(BaseModel):
    rows: list[DemandReviewDecompositionRow] = []
    totals: dict = {}


class DemandReviewNote(BaseModel):
    id: int
    cycle_id: int
    cycle_label: str
    is_carryover: bool
    kind: str               # 'RISK' | 'OPPORTUNITY' | 'DECISION'
    severity: Optional[str] = None
    note: str
    resolved: bool
    resolved_at: Optional[str] = None
    resolved_by_id: Optional[int] = None
    resolved_by_name: Optional[str] = None
    author_id: int
    author_name: str
    created_at: str


class DemandReviewNotesPayload(BaseModel):
    current_cycle_id: Optional[int] = None
    risks: list[DemandReviewNote] = []
    opportunities: list[DemandReviewNote] = []
    decisions: list[DemandReviewNote] = []


class DemandReviewNoteCreate(BaseModel):
    kind: str                       # RISK | OPPORTUNITY | DECISION
    severity: Optional[str] = None  # HIGH | MED | LOW
    note: str


class DemandReviewNoteResolveRequest(BaseModel):
    resolved: bool


class DemandReviewResponse(BaseModel):
    cycle: DemandReviewCycle
    horizon: DemandReviewHorizon
    headline: Optional[dict] = None          # placeholder — built in later phase
    decomposition: DemandReviewDecomposition
    drivers: Optional[dict] = None           # placeholder
    ro_notes: DemandReviewNotesPayload
    signoff: Optional[dict] = None           # placeholder
