"""
Polleo Planning — Streamlit App v4.0 (Demand + Supply)
=======================================================
Browser-based interface with two modules sharing one data folder:

  📊 Demand  — Forecast Engine v3.6 (dashboard, what-if, consensus, S&OP)
  📦 Supply  — Coverage, safety stock (FA-driven), reorder alerts,
               order entry, supply-plan export for CMs

Handoff: when the consensus plan is saved or the demand plan is exported,
the app writes `data/forecast_for_supply.csv` which the Supply module reads
automatically — no manual copy-paste between modules.

Run:   streamlit run app.py
Setup: Place data files in data/ folder, pip install plotly
"""

import streamlit as st
import pandas as pd
import numpy as np
import os, json, re, subprocess
from datetime import datetime, timedelta, date
from pathlib import Path
from openpyxl import load_workbook
import plotly.graph_objects as go

from constants import (
    FORECAST_WEEKS,
    TRAILING_WEEKS,
    WATCHLIST_SIZE,
    OZNAKA_TIERS,
    XYZ_CLASSES,
    YW_MULTIPLIER,
)

# ---- CONFIG ----
st.set_page_config(
    page_title="Polleo Demand Planning",
    page_icon="📊",
    layout="wide",
    initial_sidebar_state="expanded"
)

DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
CONSENSUS_DIR = DATA_DIR / "consensus"
CONSENSUS_DIR.mkdir(exist_ok=True)
CORRECTIONS_FILE = DATA_DIR / "planner_corrections.json"
OUTPUT_FILE = DATA_DIR / "Polleo_Demand_Plan.xlsx"

REQUIRED_CSV = [
    "sales_clean.csv", "sku_prices.csv", "sku_uplift.csv",
    "cat_uplift.csv", "sku_category_map.csv", "sku_subcat_map.csv"
]


# ==================================================================
# DATA LOADING
# ==================================================================

def get_current_cw():
    iso = datetime.now().isocalendar()
    return iso[0], iso[1]


def load_corrections():
    if CORRECTIONS_FILE.exists():
        with open(CORRECTIONS_FILE) as f:
            return json.load(f)
    return {"factors": {}, "vp": {}, "mp": {}, "last_saved": None}


def save_corrections(data):
    data["last_saved"] = datetime.now().strftime("%Y-%m-%d %H:%M")
    with open(CORRECTIONS_FILE, "w") as f:
        json.dump(data, f, indent=2)


def file_status():
    out = {}
    for f in REQUIRED_CSV:
        p = DATA_DIR / f
        out[f] = p.exists()
    out["planning_book"] = bool(list(DATA_DIR.glob("Polleo_Demand_Planning_Book*.xlsx")))
    out["forecast"] = OUTPUT_FILE.exists()
    if OUTPUT_FILE.exists():
        out["forecast_date"] = datetime.fromtimestamp(OUTPUT_FILE.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
    return out


@st.cache_data(ttl=60)
def load_sales_data():
    """Load full sales_clean.csv with category/oznaka info for dashboard & demand planning."""
    sc_path = DATA_DIR / "sales_clean.csv"
    if not sc_path.exists():
        return None
    sc = pd.read_csv(sc_path)
    sc["yw"] = sc["year"] * 100 + sc["week"]

    # Merge category
    cat_path = DATA_DIR / "sku_category_map.csv"
    if cat_path.exists():
        cat_df = pd.read_csv(cat_path)
        cat_map = dict(zip(cat_df["sku"], cat_df["cat"]))
        if "name" in cat_df.columns:
            name_map = dict(zip(cat_df["sku"], cat_df["name"]))
        else:
            name_map = {}
        sc["cat"] = sc["sku"].map(cat_map).fillna("OTHER")
        sc["name"] = sc["sku"].map(name_map).fillna("")
    else:
        sc["cat"] = "OTHER"
        sc["name"] = ""

    # Merge oznaka + XYZ class (from compute_xyz.py output in sku_plan_list.csv)
    plan_path = DATA_DIR / "sku_plan_list.csv"
    if plan_path.exists():
        plan_df = pd.read_csv(plan_path)
        ozn_map = dict(zip(plan_df["sku"], plan_df["oznaka"]))
        sc["oznaka"] = sc["sku"].map(ozn_map).fillna("UNCLASSIFIED")
        if "total_xyz" in plan_df.columns:
            xyz_map = dict(zip(plan_df["sku"], plan_df["total_xyz"]))
            sc["xyz"] = sc["sku"].map(xyz_map).fillna("N/A")
        else:
            sc["xyz"] = "N/A"
    else:
        sc["oznaka"] = "UNCLASSIFIED"
        sc["xyz"] = "N/A"

    return sc


@st.cache_data(ttl=60)
def get_sales_date_range(_sc_yw_min, _sc_yw_max):
    """Get min/max dates from sales data year-week bounds. Cached to avoid recomputing."""
    min_dt = pd.to_datetime(f"{_sc_yw_min // 100}{_sc_yw_min % 100}1", format="%G%V%u", errors="coerce")
    max_dt = pd.to_datetime(f"{_sc_yw_max // 100}{_sc_yw_max % 100}1", format="%G%V%u", errors="coerce")
    min_d = min_dt.date() if not pd.isna(min_dt) else date.today() - timedelta(weeks=52)
    max_d = max_dt.date() if not pd.isna(max_dt) else date.today()
    return min_d, max_d


@st.cache_data(ttl=60)
def build_sku_name_map(sc):
    """Build display_label -> sku_code map from sales data. Cached."""
    sku_name_map = {}
    for sku_code in sorted(sc["sku"].unique()):
        names_arr = sc[sc["sku"] == sku_code]["name"].dropna().unique()
        name = names_arr[0] if len(names_arr) > 0 and names_arr[0] else ""
        display = f"{name} ({sku_code})" if name else sku_code
        sku_name_map[display] = sku_code
    return sku_name_map


@st.cache_data(ttl=60)
def load_revenue_data():
    sc_path = DATA_DIR / "sales_clean.csv"
    if not sc_path.exists():
        return None
    sc = pd.read_csv(sc_path)
    sc["yw"] = sc["year"] * 100 + sc["week"]

    recent = sc.sort_values("yw").groupby("yw").agg(
        total_qty=("qty_total", "sum"),
        n_skus=("sku", "nunique")
    ).tail(13).reset_index()
    recent["year"] = recent["yw"] // 100
    recent["week"] = recent["yw"] % 100
    recent["label"] = "CW" + recent["week"].astype(str)

    return {
        "recent": recent,
        "total_skus": sc["sku"].nunique(),
        "total_rows": len(sc),
        "last_week": f"CW{int(recent['week'].iloc[-1])}",
        "last_year": int(recent["year"].iloc[-1]),
    }


@st.cache_data(ttl=60)
def load_demand_plan():
    """Read DP sheet: returns {cws, rows} where each row has baseline + factors."""
    if not OUTPUT_FILE.exists():
        return None
    wb = load_workbook(OUTPUT_FILE, data_only=True)
    ws = wb["Demand Planning"]

    cws = []
    for c in range(32, 52):
        v = ws.cell(5, c).value
        if v and str(v).startswith("CW"):
            cws.append(str(v))
        elif v is None:
            break

    BLOCK, BASE = 11, 6
    rows = []
    for idx in range(496):
        br = BASE + idx * BLOCK
        sku = ws.cell(br, 1).value
        if not sku:
            break
        rows.append({
            "SKU": sku,
            "Name": ws.cell(br, 2).value or "",
            "Category": ws.cell(br, 3).value or "",
            "OZNAKA": ws.cell(br, 4).value or "",
            "baseline": [round(float(ws.cell(br+1, 32+j).value or 0)) for j in range(len(cws))],
            "factors": [float(ws.cell(br+2, 32+j).value or 1.0) for j in range(len(cws))],
        })
    wb.close()
    return {"cws": cws, "rows": rows}


@st.cache_data(ttl=60)
def load_input_sheet(sheet_name):
    """Read VP or MP input sheet."""
    if not OUTPUT_FILE.exists():
        return None
    wb = load_workbook(OUTPUT_FILE, data_only=True)
    if sheet_name not in wb.sheetnames:
        wb.close()
        return None
    ws = wb[sheet_name]

    cws = []
    for c in range(6, 26):
        v = ws.cell(4, c).value
        if v and str(v).startswith("CW"):
            cws.append(str(v))
        elif v is None:
            break

    rows = []
    r = 5
    while r <= ws.max_row:
        sku = ws.cell(r, 1).value
        if not sku:
            r += 1
            continue
        rows.append({
            "SKU": sku,
            "Name": ws.cell(r, 2).value or "",
            "Category": ws.cell(r, 3).value or "",
            "Type": ws.cell(r, 5).value or "",
            "values": [float(ws.cell(r, 6+j).value or 0) for j in range(len(cws))],
        })
        r += 1
    wb.close()
    return {"cws": cws, "rows": rows}


@st.cache_data(ttl=60)
def load_vp_mp_inputs():
    """Load vp_input.csv and mp_input.csv as DataFrames for demand planning overlay."""
    result = {}
    for tp in ["vp", "mp"]:
        p = DATA_DIR / f"{tp}_input.csv"
        if p.exists():
            df = pd.read_csv(p)
            result[tp] = df
        else:
            result[tp] = None
    return result


@st.cache_data(ttl=60)
def load_sku_prices():
    """{sku: avg_sell_price} — empty dict if file missing."""
    p = DATA_DIR / "sku_prices.csv"
    if not p.exists():
        return {}
    pdf = pd.read_csv(p)
    return dict(zip(pdf["sku"], pdf["avg_sell_price"]))


def compute_plan_revenue(plan, sku_prices):
    """Sum(baseline * factor * price) across a plan's SKUs and CWs."""
    if not plan:
        return 0.0
    total = 0.0
    cws = plan.get("cws", [])
    for r in plan.get("rows", []):
        price = sku_prices.get(r["SKU"], 0)
        baseline = r.get("baseline", [])
        factors = r.get("factors", [])
        for j in range(len(cws)):
            b = baseline[j] if j < len(baseline) else 0
            f = factors[j] if j < len(factors) else 1.0
            total += b * f * price
    return total


def clear_all_caches():
    """Invalidate every @st.cache_data loader so fresh data is picked up.

    Called after any write that changes underlying CSV/xlsx inputs
    (forecast run, sales update, consensus save, correction apply).
    """
    load_sales_data.clear()
    load_revenue_data.clear()
    load_demand_plan.clear()
    load_input_sheet.clear()
    load_vp_mp_inputs.clear()
    load_sku_prices.clear()
    load_consensus_snapshots.clear()
    compute_accuracy_metrics.clear()
    compute_exceptions.clear()


def apply_corrections_to_xlsx():
    """Write planner_corrections.json into xlsx before engine re-reads it."""
    if not OUTPUT_FILE.exists() or not CORRECTIONS_FILE.exists():
        return
    corrections = load_corrections()
    has_data = (corrections.get("factors") or
                corrections.get("vp") or
                corrections.get("mp"))
    if not has_data:
        return

    try:
        wb = load_workbook(OUTPUT_FILE)

        # DP planner factors
        ws_dp = wb["Demand Planning"]
        cw_cols = {}
        for c in range(32, 52):
            v = ws_dp.cell(5, c).value
            if v and str(v).startswith("CW"):
                cw_cols[str(v).replace("CW", "")] = c
            elif v is None:
                break

        BLOCK, BASE = 11, 6
        for idx in range(496):
            br = BASE + idx * BLOCK
            sku = ws_dp.cell(br, 1).value
            if not sku:
                break
            factors = corrections.get("factors", {}).get(sku, {})
            for cw_num, col in cw_cols.items():
                if cw_num in factors:
                    ws_dp.cell(br + 2, col, factors[cw_num])

        # VP / MP input sheets
        for sheet_name, key in [("Demand Input VP", "vp"), ("Demand Input MP", "mp")]:
            if sheet_name not in wb.sheetnames:
                continue
            ws = wb[sheet_name]
            inp_cws = {}
            for c in range(6, 26):
                v = ws.cell(4, c).value
                if v and str(v).startswith("CW"):
                    inp_cws[str(v).replace("CW", "")] = c
                elif v is None:
                    break
            r = 5
            while r <= ws.max_row:
                sku = ws.cell(r, 1).value
                rtype = ws.cell(r, 5).value
                if not sku:
                    r += 1
                    continue
                saved = corrections.get(key, {}).get(sku, {})
                for cw_num, col in inp_cws.items():
                    cw_key = f"{rtype}_{cw_num}"
                    if cw_key in saved:
                        ws.cell(r, col, saved[cw_key])
                r += 1

        wb.save(OUTPUT_FILE)
        wb.close()
    except Exception as e:
        st.warning(f"Could not apply corrections: {e}")


# ==================================================================
# NEW DATA HELPERS: Accuracy, FVA, Exceptions, Consensus, Bridge
# ==================================================================

def save_consensus_snapshot(label=None):
    """Save the current demand plan as a versioned snapshot.
    Also captures VP/MP on-top inputs so that forecast accuracy can later be
    measured against actual wholesale (VP) and retail (MP) sales.
    """
    if not OUTPUT_FILE.exists():
        return None
    plan = load_demand_plan()
    if plan is None:
        return None
    cy, cw = get_current_cw()
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    if not label:
        label = f"CW{cw} S&OP lock"
    inputs = load_vp_mp_inputs()
    sku_prices = load_sku_prices()
    total_rev = compute_plan_revenue(plan, sku_prices)

    # ---- Capture VP / MP on-top inputs ({sku: {CW_label: value}}) ----
    vp_inputs, mp_inputs = {}, {}
    for tp, target in [("vp", vp_inputs), ("mp", mp_inputs)]:
        df = inputs.get(tp)
        if df is None or len(df) == 0:
            continue
        cw_cols = [c for c in df.columns if str(c).startswith("CW")]
        for _, row in df.iterrows():
            sku = row["sku"]
            per_cw = {}
            for c in cw_cols:
                try:
                    v = float(row.get(c, 0) or 0)
                except (TypeError, ValueError):
                    v = 0.0
                if v > 0:
                    per_cw[c] = v
            if per_cw:
                target[sku] = per_cw

    snapshot = {
        "label": label,
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "snap_year": cy,           # v3.6+: explicit year for CW→(year,week) mapping
        "cw": cw,
        "n_skus": len(plan["rows"]),
        "cws": plan["cws"],
        "total_rev": round(total_rev),
        "rows": plan["rows"],
        "vp_inputs": vp_inputs,    # v3.6+: historical VP on-top per SKU/CW
        "mp_inputs": mp_inputs,    # v3.6+: historical MP on-top per SKU/CW
    }
    filepath = CONSENSUS_DIR / f"snapshot_{ts}.json"
    with open(filepath, "w") as f:
        json.dump(snapshot, f)
    # Auto-refresh the forecast bridge so the Supply module sees the new plan.
    # Surface write failures loudly — silently swallowing here means Supply
    # would silently keep using the old bridge after a successful snapshot.
    try:
        src, err = write_forecast_for_supply()
        if err:
            st.error(f"GREŠKA: forecast_for_supply.csv nije zapisan. "
                     f"Supply modul koristi stare podatke! ({err})")
        elif src:
            ts = datetime.now().strftime("%Y-%m-%d %H:%M")
            st.success(f"✅ forecast_for_supply.csv osvježen ({ts}, source: {src})")
    except Exception as e:
        st.error(f"GREŠKA: forecast_for_supply.csv nije zapisan. "
                 f"Supply modul koristi stare podatke! ({e})")
    return filepath.name


def _parse_cw_label(lbl):
    """'CW17' -> 17. Returns None on failure."""
    try:
        return int(str(lbl).replace("CW", "").strip())
    except (ValueError, AttributeError):
        return None


def _assign_years_to_weeks(rows, start_year):
    """Given list of {sku, week, demand} preserving per-SKU input order,
    assign year to each row, incrementing on week-number wrap within a SKU."""
    out = []
    prev_sku = None
    year = start_year
    prev_wk = 0
    for r in rows:
        if r["sku"] != prev_sku:
            year = start_year
            prev_wk = 0
            prev_sku = r["sku"]
        wk = r["week"]
        if prev_wk and wk < prev_wk:
            year += 1
        out.append({"sku": r["sku"], "year": year, "week": wk,
                    "demand": int(max(0, r["demand"]))})
        prev_wk = wk
    return out


def _read_demand_output_total(wb):
    """Read 'Demand Output - Total' sheet if its cells are evaluated.
    Returns list of {sku, week, demand} or None if sheet is empty/formulas unevaluated."""
    if "Demand Output - Total" not in wb.sheetnames:
        return None
    ws = wb["Demand Output - Total"]

    # Header row 4: col 1-4 = SKU/Artikl/Grupacija/OZNAKA, col 5+ = CW17, CW18, ...
    cw_cols = []
    for c in range(5, ws.max_column + 1):
        wk = _parse_cw_label(ws.cell(4, c).value)
        if wk is not None:
            cw_cols.append((c, wk))
    if not cw_cols:
        return None

    rows = []
    any_value = False
    for r in range(5, ws.max_row + 1):
        sku = ws.cell(r, 1).value
        if not sku:
            continue
        for col, wk in cw_cols:
            v = ws.cell(r, col).value
            if v is None:
                continue
            any_value = True
            try:
                d = round(float(v))
            except (TypeError, ValueError):
                d = 0
            rows.append({"sku": sku, "week": wk, "demand": d})
    return rows if any_value else None


def _read_ontop_from_input_sheet(wb, sheet_name):
    """Read raw on-top values from 'Demand Input VP' or 'Demand Input MP'.
    These sheets have two rows per SKU (on-top demand + regular increase),
    with CW columns holding raw numbers (not formulas). Returns dict
    {(sku, week): total_ontop_value}."""
    if sheet_name not in wb.sheetnames:
        return {}
    ws = wb[sheet_name]

    # Row 4: SKU | Artikl | Grupacija | OZNAKA | Type | CW17 | CW18 | ...
    cw_cols = []
    for c in range(6, ws.max_column + 1):
        wk = _parse_cw_label(ws.cell(4, c).value)
        if wk is not None:
            cw_cols.append((c, wk))
    if not cw_cols:
        return {}

    out = {}
    for r in range(5, ws.max_row + 1):
        sku = ws.cell(r, 1).value
        if not sku:
            continue
        for col, wk in cw_cols:
            v = ws.cell(r, col).value
            if v is None:
                continue
            try:
                fv = float(v)
            except (TypeError, ValueError):
                continue
            if fv == 0:
                continue
            key = (sku, wk)
            out[key] = out.get(key, 0.0) + fv   # sum on-top + regular rows
    return out


def _load_ontop_from_csv(fname):
    """Read vp_input.csv / mp_input.csv and return {(sku, week): value}.
    These CSVs are the authoritative KAM/CM input source — fresher than the
    xlsx sheets which only update when a demand planner opens and saves the
    workbook. Columns: sku + CW<NN> per planning week."""
    p = DATA_DIR / fname
    if not p.exists():
        return {}
    df = pd.read_csv(p)
    if "sku" not in df.columns:
        return {}
    cw_cols = [c for c in df.columns if isinstance(c, str) and c.startswith("CW")]
    out = {}
    for _, row in df.iterrows():
        sku = row["sku"]
        if not sku or pd.isna(sku):
            continue
        for col in cw_cols:
            try:
                wk = int(col[2:])
                v = float(row[col])
            except (TypeError, ValueError):
                continue
            if v == 0 or pd.isna(v):
                continue
            key = (sku, wk)
            out[key] = out.get(key, 0.0) + v
    return out


def _reconstruct_forecast_from_parts(wb):
    """Primary path: rebuild TOTAL DEMAND from authoritative sources.
    TOTAL DEMAND = round(baseline × factor) + VP on-top + MP on-top.

    Baseline & factor come from the 'Demand Planning' xlsx block (rows
    br+1, br+2 — plain numbers, not formulas). Those two are xlsx-only
    because the factor override lives there.

    VP / MP on-top values come DIRECTLY from vp_input.csv / mp_input.csv,
    NOT from the xlsx 'Demand Input VP' / 'Demand Input MP' sheets —
    those sheets lag whenever KAM/CM inputs are refreshed but the xlsx
    hasn't been re-saved. CSVs are written the moment KAM files combine."""
    if "Demand Planning" not in wb.sheetnames:
        return None
    ws = wb["Demand Planning"]

    cw_cols = []
    for c in range(32, 52):
        wk = _parse_cw_label(ws.cell(5, c).value)
        if wk is None:
            break
        cw_cols.append((c, wk))
    if not cw_cols:
        return None

    vp_ontop = _load_ontop_from_csv("vp_input.csv")
    mp_ontop = _load_ontop_from_csv("mp_input.csv")

    BLOCK, BASE = 11, 6
    rows = []
    for idx in range(520):
        br = BASE + idx * BLOCK
        sku = ws.cell(br, 1).value
        if not sku:
            break
        for col, wk in cw_cols:
            try:
                b = float(ws.cell(br + 1, col).value or 0)
                f = float(ws.cell(br + 2, col).value or 1.0)
            except (TypeError, ValueError):
                b, f = 0.0, 1.0
            base_adj = round(b * f)
            on_top = vp_ontop.get((sku, wk), 0.0) + mp_ontop.get((sku, wk), 0.0)
            rows.append({"sku": sku, "week": wk,
                         "demand": round(base_adj + on_top)})
    return rows if rows else None


def write_forecast_for_supply():
    """Write data/forecast_for_supply.csv from the current Demand Plan.

    Primary source: reconstruct TOTAL = baseline × factor + VP on-top + MP on-top,
    where baseline & factor come from the xlsx 'Demand Planning' sheet
    (factor override only lives there) and VP/MP on-top come directly from
    vp_input.csv / mp_input.csv (always fresh — updated the moment KAM/CM
    templates combine). No Excel re-save required.

    Fallback: 'Demand Output - Total' sheet if reconstruction fails
    (e.g. Demand Planning sheet missing or empty).

    Returns: (source, error_msg) tuple.
        source ∈ {"reconstructed", "output_total", None}.
        error_msg: str or None. Non-None only when the write itself failed
        (e.g. file locked by Excel) — callers should surface this so the user
        knows Supply is still on the old bridge.
    """
    if not OUTPUT_FILE.exists():
        return (None, None)
    wb = load_workbook(OUTPUT_FILE, data_only=True)

    # Primary: reconstruct from parts — picks up latest vp_input.csv /
    # mp_input.csv without requiring the xlsx to be re-saved first.
    # Fallback: xlsx 'Demand Output - Total' sheet (only useful if CSV
    # reconstruction can't find the Demand Planning block).
    rows = _reconstruct_forecast_from_parts(wb)
    source = "reconstructed"
    if rows is None:
        rows = _read_demand_output_total(wb)
        source = "output_total"
    if not rows:
        return (None, None)

    cy, _ = get_current_cw()
    out = _assign_years_to_weeks(rows, cy)
    df = pd.DataFrame(out)
    # Drop zeros to keep the file small
    df = df[df["demand"] > 0].reset_index(drop=True)
    try:
        df.to_csv(DATA_DIR / "forecast_for_supply.csv", index=False)
    except Exception as e:
        return (None, f"{type(e).__name__}: {e}")
    return (source, None)


@st.cache_data(ttl=300)
def _load_past_forecast_total_for_cw(target_cw: int):
    """Return {sku: total_units} for `CW{target_cw}` taken from the most
    recent archived plan in data/plan_history/ that still has that column.
    Returns {} if no archive contains the requested week.

    Used to proxy the current week's chart point in the Revenue page —
    the live xlsx no longer has the current week (CW_START = cw + 1 every
    Monday run), but last week's archive does.
    """
    hist_dir = DATA_DIR / "plan_history"
    if not hist_dir.exists():
        return {}
    files = sorted(hist_dir.glob("Polleo_Demand_Plan_*.xlsx"), reverse=True)
    if not files:
        return {}
    target_label = f"CW{target_cw}"
    try:
        from openpyxl import load_workbook as _lw
    except Exception:
        return {}
    for fp in files:
        try:
            wb = _lw(fp, data_only=True)
            if "Demand Output - Total" not in wb.sheetnames:
                wb.close()
                continue
            ws = wb["Demand Output - Total"]
            headers = [ws.cell(4, c).value for c in range(1, ws.max_column + 1)]
            if target_label not in headers:
                wb.close()
                continue
            col_idx = headers.index(target_label) + 1
            out = {}
            for r in range(5, ws.max_row + 1):
                sku = ws.cell(r, 1).value
                v = ws.cell(r, col_idx).value
                if sku and v is not None:
                    try:
                        out[sku] = float(v)
                    except (TypeError, ValueError):
                        pass
            wb.close()
            return out
        except Exception:
            continue
    return {}


@st.cache_data(ttl=30)
def load_consensus_snapshots():
    """List all saved consensus snapshots."""
    snapshots = []
    for f in sorted(CONSENSUS_DIR.glob("snapshot_*.json"), reverse=True):
        try:
            with open(f) as fh:
                data = json.load(fh)
            data["filename"] = f.name
            snapshots.append(data)
        except Exception:
            pass
    return snapshots


@st.cache_data(ttl=120)
def compute_accuracy_metrics(sc, _dummy_hash=None):
    """Compare last S&OP snapshot forecast vs actual sales for overlapping weeks."""
    if sc is None:
        return None
    snapshots = load_consensus_snapshots()
    if not snapshots:
        return None

    snap = snapshots[0]  # latest snapshot
    snap_rows = {r["SKU"]: r for r in snap.get("rows", [])}
    snap_cws = snap.get("cws", [])
    if not snap_cws:
        return None

    # Get actual weekly sales per SKU for the last 4 weeks
    actuals = sc.groupby(["sku", "year", "week", "yw"])["qty_total"].sum().reset_index()
    actuals["cw_label"] = "CW" + actuals["week"].astype(str)
    recent_yws = sorted(actuals["yw"].unique())[-4:]
    actuals = actuals[actuals["yw"].isin(recent_yws)]

    # Build oznaka/cat maps
    plan_path = DATA_DIR / "sku_plan_list.csv"
    ozn_map, cat_map = {}, {}
    if plan_path.exists():
        pl = pd.read_csv(plan_path)
        ozn_map = dict(zip(pl["sku"], pl["oznaka"]))
        cat_map = dict(zip(pl["sku"], pl["cat"]))

    results = []
    for _, arow in actuals.iterrows():
        sku = arow["sku"]
        cw_label = arow["cw_label"]
        actual = int(arow["qty_total"])
        if actual == 0:
            continue

        if sku not in snap_rows or cw_label not in snap_cws:
            continue

        row = snap_rows[sku]
        j = snap_cws.index(cw_label)
        baseline = row["baseline"][j] if j < len(row["baseline"]) else 0
        factor = row["factors"][j] if j < len(row["factors"]) else 1.0
        forecast = round(baseline * factor)

        error = abs(actual - forecast)
        fa = max(0, 1 - error / actual)
        bias = forecast - actual
        hit = 1 if (error / actual) <= 0.3 else 0

        results.append({
            "sku": sku, "oznaka": ozn_map.get(sku, ""),
            "cat": cat_map.get(sku, ""), "cw": cw_label,
            "forecast": forecast, "actual": actual,
            "error": error, "fa": fa, "bias": bias, "hit": hit,
        })

    if not results:
        return None
    return pd.DataFrame(results)


@st.cache_data(ttl=120)
def compute_exceptions(sc):
    """Detect anomalies: spikes, zero-sales, high-error SKUs."""
    if sc is None:
        return {"spikes": [], "zero_sales": [], "high_error": []}
    plan_path = DATA_DIR / "sku_plan_list.csv"
    plan_df = pd.read_csv(plan_path) if plan_path.exists() else None
    ozn_map = dict(zip(plan_df["sku"], plan_df["oznaka"])) if plan_df is not None else {}
    name_map = {}
    cat_path = DATA_DIR / "sku_category_map.csv"
    if cat_path.exists():
        cm = pd.read_csv(cat_path)
        if "name" in cm.columns:
            name_map = dict(zip(cm["sku"], cm["name"]))

    weekly = sc.groupby(["sku", "year", "week", "yw"])["qty_total"].sum().reset_index()
    weekly = weekly.sort_values(["sku", "yw"])

    spikes = []
    zero_sales = []
    # Get last 2 weeks per SKU for spike detection
    for sku, grp in weekly.groupby("sku"):
        last_rows = grp.tail(5)
        if len(last_rows) >= 2:
            prev = last_rows.iloc[-2]["qty_total"]
            curr = last_rows.iloc[-1]["qty_total"]
            if prev > 5 and curr > 0:
                change = (curr - prev) / prev
                if abs(change) > 0.3:
                    spikes.append({
                        "sku": sku, "name": name_map.get(sku, ""),
                        "oznaka": ozn_map.get(sku, ""),
                        "change": round(change * 100),
                        "prev": int(prev), "curr": int(curr),
                        "week": f"CW{int(last_rows.iloc[-1]['week'])}",
                    })
        # Zero-sales detection: last 3+ weeks = 0
        last_3 = grp.tail(3)
        if len(last_3) >= 3 and last_3["qty_total"].sum() == 0:
            zero_sales.append({
                "sku": sku, "name": name_map.get(sku, ""),
                "oznaka": ozn_map.get(sku, ""),
                "weeks_zero": len(last_3[last_3["qty_total"] == 0]),
            })

    spikes.sort(key=lambda x: abs(x["change"]), reverse=True)
    return {"spikes": spikes[:15], "zero_sales": zero_sales[:15]}


def compute_revenue_bridge(current_plan, snapshots):
    """Compute waterfall between last snapshot and current plan."""
    if not snapshots or current_plan is None:
        return None
    sku_prices = load_sku_prices()

    # Current revenue per SKU
    curr_rev = {}
    for r in current_plan["rows"]:
        rev = 0
        price = sku_prices.get(r["SKU"], 0)
        for j in range(len(current_plan["cws"])):
            b = r["baseline"][j] if j < len(r["baseline"]) else 0
            f = r["factors"][j] if j < len(r["factors"]) else 1.0
            rev += b * f * price
        curr_rev[r["SKU"]] = rev

    # Previous snapshot revenue per SKU
    prev_snap = snapshots[0]
    prev_rev = {}
    for r in prev_snap.get("rows", []):
        rev = 0
        price = sku_prices.get(r["SKU"], 0)
        for j in range(len(prev_snap.get("cws", []))):
            b = r["baseline"][j] if j < len(r["baseline"]) else 0
            f = r["factors"][j] if j < len(r["factors"]) else 1.0
            rev += b * f * price
        prev_rev[r["SKU"]] = rev

    prev_total = sum(prev_rev.values())
    curr_total = sum(curr_rev.values())
    # Breakdown
    all_skus = set(list(curr_rev.keys()) + list(prev_rev.keys()))
    volume_change = 0
    new_skus_rev = 0
    removed_skus_rev = 0
    for sku in all_skus:
        c = curr_rev.get(sku, 0)
        p = prev_rev.get(sku, 0)
        if p == 0 and c > 0:
            new_skus_rev += c
        elif c == 0 and p > 0:
            removed_skus_rev += p
        else:
            volume_change += (c - p)

    # VP/MP contribution
    inputs = load_vp_mp_inputs()
    vp_mp_rev = 0
    for tp in ["vp", "mp"]:
        if inputs[tp] is not None:
            for _, row in inputs[tp].iterrows():
                price = sku_prices.get(row["sku"], 0)
                cw_cols = [c for c in row.index if c.startswith("CW")]
                vp_mp_rev += sum(float(row[c]) for c in cw_cols if pd.notna(row[c])) * price

    return {
        "prev_total": round(prev_total),
        "curr_total": round(curr_total),
        "volume_change": round(volume_change - vp_mp_rev),
        "vp_mp": round(vp_mp_rev),
        "new_skus": round(new_skus_rev),
        "removed": round(removed_skus_rev),
        "prev_label": prev_snap.get("label", "Previous"),
    }


# ==================================================================
# PAGE RENDERERS
# ==================================================================

def page_dashboard():
    st.title("Dashboard")
    cy, cw = get_current_cw()
    st.caption(f"Week {cw}, {cy}")

    sc = load_sales_data()
    if sc is None:
        st.info("Upload sales data to get started.")
        return

    # --- Filters ---
    c1, c2, c3 = st.columns([2, 2, 1])

    all_cats = sorted(sc["cat"].dropna().unique().tolist())
    cat_filter = c1.selectbox("Kategorija", ["All"] + all_cats, key="dash_cat")

    ozn_options = ["All"] + OZNAKA_TIERS
    ozn_filter = c2.selectbox("Oznaka", ozn_options, key="dash_ozn")

    view_mode = c3.radio("View", ["Weekly", "Monthly"], horizontal=True, key="dash_view")

    # Apply category & oznaka filters
    filtered = sc.copy()
    if cat_filter != "All":
        filtered = filtered[filtered["cat"] == cat_filter]
    if ozn_filter != "All":
        filtered = filtered[filtered["oznaka"] == ozn_filter]

    # SKU filter (after cat/oznaka narrowing)
    available_skus = sorted(filtered["sku"].unique().tolist())
    sku_filter = st.selectbox(
        "SKU", ["All SKUs"] + available_skus,
        key="dash_sku"
    )
    if sku_filter != "All SKUs":
        filtered = filtered[filtered["sku"] == sku_filter]

    if filtered.empty:
        st.warning("No data for selected filters.")
        return

    # --- Aggregate ---
    if view_mode == "Weekly":
        agg = filtered.groupby(["year", "week"])["qty_total"].sum().reset_index()
        agg["yw"] = agg["year"] * 100 + agg["week"]
        agg = agg.sort_values("yw")
        agg["label"] = "CW" + agg["week"].astype(str) + " '" + (agg["year"] % 100).astype(str)
        x_col = "label"
        y_col = "qty_total"
    else:
        filtered_copy = filtered.copy()
        # Convert year+week to approximate month
        filtered_copy["month"] = pd.to_datetime(
            filtered_copy["year"].astype(str) + filtered_copy["week"].astype(str) + "1",
            format="%G%V%u", errors="coerce"
        ).dt.to_period("M")
        filtered_copy = filtered_copy.dropna(subset=["month"])
        agg = filtered_copy.groupby("month")["qty_total"].sum().reset_index()
        agg = agg.sort_values("month")
        agg["label"] = agg["month"].astype(str)
        x_col = "label"
        y_col = "qty_total"

    # --- Line chart using Plotly ---
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=agg[x_col], y=agg[y_col],
        mode="lines+markers+text",
        line=dict(color="#2F5496", width=2.5),
        marker=dict(size=6),
        text=[f"{int(v):,}" for v in agg[y_col]],
        textposition="top center",
        textfont=dict(size=10, color="#2F5496"),
        name="Quantity"
    ))
    fig.update_layout(
        xaxis_title=None,
        yaxis_title="Quantity",
        yaxis=dict(tickformat=",", separatethousands=True),
        margin=dict(l=60, r=20, t=40, b=40),
        height=420,
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)",
        paper_bgcolor="rgba(0,0,0,0)",
    )
    fig.update_xaxes(showgrid=False, tickangle=-45)
    fig.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)")

    title_suffix = ""
    if sku_filter != "All SKUs":
        # Show name if we can
        names = sc[sc["sku"] == sku_filter]["name"].dropna().unique()
        if len(names) > 0 and names[0]:
            title_suffix = f" — {sku_filter} ({names[0]})"
        else:
            title_suffix = f" — {sku_filter}"

    st.subheader(f"Sales volume ({view_mode.lower()}){title_suffix}")
    st.plotly_chart(fig, use_container_width=True)

    # --- Exception flags ---
    st.divider()
    st.subheader("Attention needed")
    exceptions = compute_exceptions(sc)
    n_total = len(exceptions["spikes"]) + len(exceptions["zero_sales"])

    if n_total == 0:
        st.success("No exceptions detected.")
    else:
        c1, c2 = st.columns(2)
        c1.metric("Demand spikes", len(exceptions["spikes"]), help=">30% week-over-week change")
        c2.metric("Zero-sales alerts", len(exceptions["zero_sales"]), help="3+ weeks with no sales")

        if exceptions["spikes"]:
            with st.expander(f"🔴 Demand spikes ({len(exceptions['spikes'])})"):
                spike_df = pd.DataFrame(exceptions["spikes"])
                spike_df["change"] = spike_df["change"].apply(lambda x: f"{'+' if x > 0 else ''}{x}%")
                st.dataframe(spike_df[["sku", "name", "oznaka", "change", "prev", "curr", "week"]].rename(
                    columns={"sku":"SKU","name":"Name","oznaka":"Tier","change":"Change","prev":"Prev","curr":"Curr","week":"Week"}
                ), use_container_width=True, hide_index=True)

        if exceptions["zero_sales"]:
            with st.expander(f"🟡 Zero-sales alerts ({len(exceptions['zero_sales'])})"):
                zero_df = pd.DataFrame(exceptions["zero_sales"])
                st.dataframe(zero_df[["sku","name","oznaka","weeks_zero"]].rename(
                    columns={"sku":"SKU","name":"Name","oznaka":"Tier","weeks_zero":"Weeks at 0"}
                ), use_container_width=True, hide_index=True)


def page_demand_planning():
    st.title("Demand planning")
    cy, cw = get_current_cw()

    sc = load_sales_data()
    plan = load_demand_plan()

    if sc is None:
        st.warning("Upload sales data first.")
        return

    # --- Filters row ---
    c1, c2, c3 = st.columns([2, 2, 2])

    all_cats = sorted(sc["cat"].dropna().unique().tolist())
    cat_filter = c1.selectbox("Kategorija", ["All"] + all_cats, key="dp_cat")

    ozn_options = ["All"] + OZNAKA_TIERS
    ozn_filter = c2.selectbox("Oznaka", ozn_options, key="dp_ozn")

    # XYZ multiselect — default = all present classes (no-op filter).
    # Falls back gracefully if compute_xyz.py hasn't been run yet.
    xyz_present = sorted(sc["xyz"].dropna().astype(str).unique().tolist()) if "xyz" in sc.columns else []
    xyz_filter = c3.multiselect(
        "XYZ class",
        options=xyz_present,
        default=xyz_present,
        key="dp_xyz",
        help="X = stable (CV<0.5), Y = moderate (0.5–1.0), Z = erratic (CV>1.0). "
             "Run compute_xyz.py to refresh.",
    )

    # Date range picker

    # Date range from sales data (cached computation)
    yw_min = int(sc["yw"].min())
    yw_max = int(sc["yw"].max())
    min_date, max_date = get_sales_date_range(yw_min, yw_max)

    # Extend max date to cover the 13-week forecast horizon
    forecast_end = max_date + timedelta(weeks=14)
    default_start = max(min_date, max_date - timedelta(weeks=26))

    dc1, dc2 = st.columns(2)
    date_from = dc1.date_input("From", value=default_start, min_value=min_date, max_value=forecast_end, key="dp_date_from")
    date_to = dc2.date_input("To", value=forecast_end, min_value=min_date, max_value=forecast_end, key="dp_date_to")

    # Apply cat/oznaka/xyz to narrow SKU list
    filtered_sc = sc
    if cat_filter != "All":
        filtered_sc = filtered_sc[filtered_sc["cat"] == cat_filter]
    if ozn_filter != "All":
        filtered_sc = filtered_sc[filtered_sc["oznaka"] == ozn_filter]
    if "xyz" in filtered_sc.columns and xyz_filter and xyz_present and len(xyz_filter) < len(xyz_present):
        filtered_sc = filtered_sc[filtered_sc["xyz"].astype(str).isin(xyz_filter)]

    # Build SKU name list for dropdown (cached)
    sku_name_map = build_sku_name_map(filtered_sc)

    sku_options = ["All"] + sorted(sku_name_map.keys())
    sku_selection = st.selectbox("SKU", sku_options, key="dp_sku")

    if sku_selection == "All":
        selected_skus = list(sku_name_map.values())
        sku_filter = None  # means all
    else:
        sku_filter = sku_name_map.get(sku_selection)
        selected_skus = [sku_filter] if sku_filter else []

    if not selected_skus:
        st.info("No SKUs match your filters.")
        return

    # --- Historical data ---
    sku_data = sc[sc["sku"].isin(selected_skus)].copy()
    sku_data = sku_data.sort_values("yw")

    # Resolve display name
    if sku_filter:
        names = sku_data[sku_data["sku"] == sku_filter]["name"].dropna().unique()
        sku_name = names[0] if len(names) > 0 and names[0] else sku_filter
        display_title = f"{sku_name} ({sku_filter})"
    else:
        n_skus = len(selected_skus)
        label_parts = []
        if cat_filter != "All":
            label_parts.append(cat_filter)
        if ozn_filter != "All":
            label_parts.append(ozn_filter)
        if xyz_filter and xyz_present and len(xyz_filter) < len(xyz_present):
            label_parts.append("XYZ: " + ",".join(xyz_filter))
        group_label = " · ".join(label_parts) if label_parts else "All SKUs"
        display_title = f"{group_label} ({n_skus} SKUs)"
        sku_name = group_label

    # Filter by date range using year-week integers (much faster than date conversion)
    from_iso = date_from.isocalendar()
    to_iso = date_to.isocalendar()
    yw_from = from_iso[0] * 100 + from_iso[1]
    yw_to = to_iso[0] * 100 + to_iso[1]
    sku_data = sku_data[(sku_data["yw"] >= yw_from) & (sku_data["yw"] <= yw_to)]

    # Aggregate weekly (sum across all selected SKUs)
    hist = sku_data.groupby(["year", "week", "yw"])["qty_total"].sum().reset_index()
    hist = hist.sort_values("yw")
    hist["label"] = "CW" + hist["week"].astype(str)

    # --- Check for promo flags ---
    has_promo = False
    promo_weeks = set()
    uplift_path = DATA_DIR / "sku_uplift.csv"
    if uplift_path.exists():
        try:
            uplift_df = pd.read_csv(uplift_path)
            sku_uplift = uplift_df[uplift_df["sku"].isin(selected_skus)]
            if not sku_uplift.empty and "promo_flag" in sku_uplift.columns:
                promo_rows = sku_uplift[sku_uplift["promo_flag"] == 1]
                if not promo_rows.empty:
                    has_promo = True
                    for _, pr in promo_rows.iterrows():
                        promo_weeks.add(int(pr["year"]) * 100 + int(pr["week"]))
        except Exception:
            pass

    # --- Forecast data (aggregate across selected SKUs) ---
    forecast_cws = []
    forecast_baseline = []
    forecast_adjusted = []
    vp_values = []
    mp_values = []

    if plan is not None:
        matching_plan_rows = [r for r in plan["rows"] if r["SKU"] in selected_skus]
        if matching_plan_rows:
            forecast_cws = plan["cws"]
            n_cws = len(forecast_cws)
            # Sum baselines and adjusted across all matching SKUs
            forecast_baseline = [0] * n_cws
            forecast_adjusted = [0] * n_cws
            for row in matching_plan_rows:
                for j in range(n_cws):
                    b = row["baseline"][j] if j < len(row["baseline"]) else 0
                    f = row["factors"][j] if j < len(row["factors"]) else 1.0
                    forecast_baseline[j] += round(b)
                    forecast_adjusted[j] += round(b * f)

    # Load VP/MP inputs (aggregate across selected SKUs)
    inputs = load_vp_mp_inputs()
    for tp, label_tp in [("vp", "VP"), ("mp", "MP")]:
        if inputs[tp] is not None and forecast_cws:
            sku_inp = inputs[tp][inputs[tp]["sku"].isin(selected_skus)]
            if not sku_inp.empty:
                vals = [0.0] * len(forecast_cws)
                for _, inp_row in sku_inp.iterrows():
                    for j, cw_label in enumerate(forecast_cws):
                        v = inp_row.get(cw_label, 0)
                        vals[j] += float(v) if pd.notna(v) else 0
                if tp == "vp":
                    vp_values = vals
                else:
                    mp_values = vals

    # --- Compute total forecast (baseline adjusted + VP + MP) ---
    forecast_total = []
    if forecast_cws:
        for j in range(len(forecast_cws)):
            base = forecast_adjusted[j] if j < len(forecast_adjusted) else 0
            vp_add = vp_values[j] if j < len(vp_values) else 0
            mp_add = mp_values[j] if j < len(mp_values) else 0
            forecast_total.append(round(base + vp_add + mp_add))

    # --- What-if scenario toggle ---
    st.divider()
    wif_cols = st.columns(3)
    show_stat = wif_cols[0].checkbox("Statistical forecast", value=True, key="wif_stat")
    show_factors = wif_cols[1].checkbox("+ Planner factors", value=True, key="wif_fac")
    show_vpmp = wif_cols[2].checkbox("+ VP/MP inputs", value=True, key="wif_vpmp")

    # Compute what-if total based on toggles
    whatif_total = []
    if forecast_cws:
        for j in range(len(forecast_cws)):
            val = 0
            if show_stat:
                val = forecast_baseline[j] if j < len(forecast_baseline) else 0
                if show_factors:
                    val = forecast_adjusted[j] if j < len(forecast_adjusted) else val
            if show_vpmp:
                val += (vp_values[j] if j < len(vp_values) else 0)
                val += (mp_values[j] if j < len(mp_values) else 0)
            whatif_total.append(round(val))

    # --- Build the chart (continuous line: actuals → forecast) ---

    fig = go.Figure()

    # Limit actuals to last 12 completed weeks before forecast starts
    fc_start_cw = int(forecast_cws[0].replace("CW","")) if forecast_cws else cw + 1
    hist_before_fc = hist[hist["week"] < fc_start_cw] if not hist.empty else hist
    hist_display = hist_before_fc.tail(12)

    # Build unified ordered x-axis labels
    hist_labels = hist_display["label"].tolist() if not hist_display.empty else []
    fc_labels = forecast_cws if forecast_cws else []
    all_x_labels = hist_labels + fc_labels

    # Actuals line (blue)
    if not hist_display.empty:
        fig.add_trace(go.Scatter(
            x=hist_labels,
            y=hist_display["qty_total"].tolist(),
            mode="lines+markers+text",
            line=dict(color="#2F5496", width=2.5),
            marker=dict(size=6),
            text=[f"{int(v):,}" for v in hist_display["qty_total"]],
            textposition="top center",
            textfont=dict(size=9, color="#2F5496"),
            name="Actual sales",
        ))

    # Forecast line (green) — connect from last actual for continuity
    if forecast_cws and whatif_total and any(v > 0 for v in whatif_total):
        fc_x = list(fc_labels)
        fc_y = list(whatif_total)
        # Bridge: start forecast line from last actual point
        if not hist_display.empty:
            fc_x = [hist_labels[-1]] + fc_x
            fc_y = [int(hist_display["qty_total"].iloc[-1])] + fc_y
        fig.add_trace(go.Scatter(
            x=fc_x, y=fc_y,
            mode="lines+markers+text",
            line=dict(color="#548235", width=2.5),
            marker=dict(size=6),
            text=[""] + [f"{int(v):,}" for v in whatif_total],  # skip bridge point text
            textposition="top center",
            textfont=dict(size=9, color="#548235"),
            name="Forecast (total)",
        ))

    # Stat-only baseline (dashed) if VP/MP present
    has_vp_mp = (vp_values and any(v > 0 for v in vp_values)) or \
                (mp_values and any(v > 0 for v in mp_values))
    if forecast_cws and forecast_adjusted and has_vp_mp and show_vpmp:
        fig.add_trace(go.Scatter(
            x=fc_labels, y=forecast_adjusted,
            mode="lines", line=dict(color="#548235", width=1.5, dash="dot"),
            name="Forecast (stat. only)", opacity=0.5,
        ))

    # VP bars
    if forecast_cws and vp_values and any(v > 0 for v in vp_values) and show_vpmp:
        fig.add_trace(go.Bar(
            x=fc_labels, y=vp_values, name="VP input",
            marker_color="rgba(47, 84, 150, 0.45)",
            text=[f"{int(v):,}" if v > 0 else "" for v in vp_values],
            textposition="outside", textfont=dict(size=9, color="#2F5496"), width=0.4,
        ))

    # MP bars
    if forecast_cws and mp_values and any(v > 0 for v in mp_values) and show_vpmp:
        fig.add_trace(go.Bar(
            x=fc_labels, y=mp_values, name="MP input",
            marker_color="rgba(255, 107, 53, 0.45)",
            text=[f"{int(v):,}" if v > 0 else "" for v in mp_values],
            textposition="outside", textfont=dict(size=9, color="#D85A30"), width=0.4,
        ))

    # Enforce x-axis order
    fig.update_layout(
        barmode="overlay",
        xaxis=dict(categoryorder="array", categoryarray=all_x_labels, showgrid=False, tickangle=-45),
        yaxis=dict(title="Quantity", tickformat=",", separatethousands=True,
                   showgrid=True, gridcolor="rgba(200,200,200,0.3)"),
        margin=dict(l=60, r=20, t=40, b=40), height=450,
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
    )

    st.subheader(display_title)

    # Oznaka / category info
    if sku_filter and not sku_data.empty:
        ozn = sku_data["oznaka"].iloc[0] if not sku_data.empty else ""
        cat = sku_data["cat"].iloc[0] if not sku_data.empty else ""
        st.caption(f"{cat}  ·  {ozn}")

    st.plotly_chart(fig, use_container_width=True)

    # --- Data table below chart ---
    st.subheader("Numbers")

    table_rows = {}

    # Historical weeks
    for _, r in hist.iterrows():
        lbl = r["label"]
        table_rows[lbl] = {
            "Week": lbl,
            "Actual": int(r["qty_total"]),
            "Forecast (stat.)": "",
            "VP input": "",
            "MP input": "",
            "Forecast (total)": "",
            "Promo": "🔶" if int(r["yw"]) in promo_weeks else "",
        }

    # Forecast weeks
    for j, cw_label in enumerate(forecast_cws):
        adj = forecast_adjusted[j] if j < len(forecast_adjusted) else 0
        vp_v = vp_values[j] if j < len(vp_values) else 0
        mp_v = mp_values[j] if j < len(mp_values) else 0
        total = whatif_total[j] if j < len(whatif_total) else 0

        if cw_label in table_rows:
            table_rows[cw_label]["Forecast (stat.)"] = adj
            table_rows[cw_label]["VP input"] = int(vp_v) if vp_v > 0 else ""
            table_rows[cw_label]["MP input"] = int(mp_v) if mp_v > 0 else ""
            table_rows[cw_label]["Forecast (total)"] = total
        else:
            table_rows[cw_label] = {
                "Week": cw_label,
                "Actual": "",
                "Forecast (stat.)": adj,
                "VP input": int(vp_v) if vp_v > 0 else "",
                "MP input": int(mp_v) if mp_v > 0 else "",
                "Forecast (total)": total,
                "Promo": "",
            }

    if table_rows:
        tdf = pd.DataFrame(list(table_rows.values()))
        st.dataframe(tdf, use_container_width=True, hide_index=True)

    # --- Planner factors section (only for single SKU) ---
    if plan is not None and sku_filter:
        plan_rows = [r for r in plan["rows"] if r["SKU"] == sku_filter]
        if plan_rows:
            with st.expander("✏️ Edit planner factors for this SKU"):
                row = plan_rows[0]
                corrections = load_corrections()
                saved = corrections.get("factors", {}).get(sku_filter, {})

                factor_cols = {}
                cols = st.columns(min(len(forecast_cws), 13))
                for j, cw_label in enumerate(forecast_cws):
                    cw_num = cw_label.replace("CW", "")
                    default = saved.get(cw_num, row["factors"][j] if j < len(row["factors"]) else 1.0)
                    factor_cols[cw_num] = cols[j % len(cols)].number_input(
                        cw_label, min_value=0.0, max_value=5.0,
                        value=float(default), step=0.05,
                        format="%.2f", key=f"dpf_{cw_label}"
                    )

                if st.button("💾 Save factors", type="primary", key="dp_save_factors"):
                    new_factors = {}
                    for cw_num, val in factor_cols.items():
                        if val != 1.0:
                            new_factors[cw_num] = val
                    if new_factors:
                        corrections.setdefault("factors", {})[sku_filter] = new_factors
                    elif sku_filter in corrections.get("factors", {}):
                        del corrections["factors"][sku_filter]
                    save_corrections(corrections)
                    st.success("Saved!")
                    st.rerun()


def page_update_sales():
    st.title("Update sales data")
    st.caption("Upload sales files — one per country (CRO, SLO, AUT) or a single combined file. Columns are auto-detected.")

    uploaded_files = st.file_uploader(
        "Upload sales file(s) (.xlsx)",
        type=["xlsx", "xls"],
        accept_multiple_files=True,
        help="Upload 1–3 files (e.g. one per country database). Needs columns: Datum, Artikal, Količina, Vrijednost (€), Tip dok. — column order doesn't matter, extra columns are ignored."
    )

    merge_ws = st.checkbox(
        "Wholesale add-on mode (e.g. RIZ-only upload)",
        value=False,
        help="Use this when your upload only contains wholesale documents (e.g. RIZ). "
             "Existing retail/webshop quantities are preserved; wholesale is added. "
             "Without this, rows in the upload **replace** existing rows entirely."
    )
    if merge_ws:
        st.info("📋 Add-on mode — wholesale qty + ruc will be **added** to existing "
                "rows; retail & webshop are untouched.")

    if uploaded_files:
        saved_paths = []
        for uf in uploaded_files:
            # Save each with a unique name to avoid overwrites
            safe_name = uf.name.replace(" ", "_")
            fpath = DATA_DIR / f"upload_{safe_name}"
            with open(fpath, "wb") as f:
                f.write(uf.getvalue())
            saved_paths.append(fpath)
            st.success(f"✅ {uf.name} ({uf.size:,} bytes)")

        # Preview each file
        for uf, fpath in zip(uploaded_files, saved_paths):
            try:
                import openpyxl as _oxl
                _wb = _oxl.load_workbook(fpath, read_only=True)
                _sheets = _wb.sheetnames; _wb.close()
                _skip = {f'Sheet{i}' for i in range(1, 10)}
                _data_sheets = [s for s in _sheets if s not in _skip]
                _target = _data_sheets[0] if _data_sheets else _sheets[0]
                preview = pd.read_excel(fpath, sheet_name=_target, nrows=5)
                with st.expander(f"Preview: {uf.name}" + (f" (sheet: {_target})" if len(_sheets) > 1 else "")):
                    st.dataframe(preview, use_container_width=True)
            except Exception:
                pass

        if st.button("🔄 Process sales update", type="primary", use_container_width=True):
            script = Path("update_sales.py").resolve()
            if not script.exists():
                st.error("update_sales.py not found next to app.py")
                return
            # Pass all saved file paths as arguments
            cmd = ["python", str(script)] + [str(p.resolve()) for p in saved_paths]
            if merge_ws:
                cmd.append("--merge-wholesale")
            import copy as _copy
            env = _copy.copy(os.environ)
            env["PYTHONIOENCODING"] = "utf-8"
            with st.spinner(f"Processing {len(saved_paths)} file(s)..."):
                r = subprocess.run(
                    cmd,
                    cwd=str(DATA_DIR.resolve()),
                    stdin=subprocess.DEVNULL,
                    capture_output=True, text=True, timeout=300,
                    env=env, encoding="utf-8", errors="replace"
                )
            if r.returncode == 0:
                st.success(f"Sales updated from {len(saved_paths)} file(s)!")
                clear_all_caches()
            else:
                st.error("Failed")
            st.code(r.stdout[-2000:] + "\n" + r.stderr[-1000:])

    with st.expander("Or upload CSV files directly"):
        for fname in REQUIRED_CSV:
            f = st.file_uploader(f"{fname}", type=["csv"], key=f"csv_{fname}")
            if f:
                (DATA_DIR / fname).write_bytes(f.getvalue())
                st.success(f"Saved {fname}")
                clear_all_caches()


def page_run_forecast():
    cy, cw = get_current_cw()
    st.title("Run forecast engine")
    st.caption(f"Generates 13-week forecast: CW{cw+1} → CW{cw+13}")

    status = file_status()
    missing = [f for f in REQUIRED_CSV if not status[f]]

    if missing:
        st.error(f"Missing: {', '.join(missing)}")
        return
    if not status["planning_book"]:
        st.error("Missing Polleo_Demand_Planning_Book.xlsx in data/")
        return

    st.success("All data files present — ready to forecast.")

    c1, c2, c3 = st.columns(3)
    c1.info(f"**Forecast window**\nCW{cw+1} → CW{cw+13}")
    corr = load_corrections()
    n_fac = sum(1 for v in corr.get("factors", {}).values() if any(float(x) != 1.0 for x in v.values()))
    c2.info(f"**Corrections**\n{n_fac} SKUs with factors")
    if status["forecast"]:
        c3.info(f"**Last run**\n{status.get('forecast_date','')}")
    else:
        c3.info("**Last run**\nNever")

    if st.button("🚀 Run forecast engine", type="primary", use_container_width=True):
        script = Path("forecast_engine.py").resolve()
        if not script.exists():
            st.error("forecast_engine.py not found next to app.py")
            return

        planning_book = list(DATA_DIR.glob("Polleo_Demand_Planning_Book*.xlsx"))[0]

        # ---- v3.6: PRE-FLIGHT Excel-lock check ----
        # On Windows, if Excel has the file open, openpyxl's save() will fail
        # with PermissionError — but the engine catches the exception, prints
        # it, and exits 0. The app then falsely reports success. Check
        # writability up front and bail loudly if the file is locked.
        if OUTPUT_FILE.exists():
            try:
                # Try to open for append (doesn't modify content but requires
                # write access). On locked files this raises PermissionError.
                with open(OUTPUT_FILE, "ab"):
                    pass
            except PermissionError:
                st.error(
                    f"❌ Cannot write to `{OUTPUT_FILE.name}` — the file is "
                    f"locked by another program (almost always Excel on Windows).\n\n"
                    f"**Fix:** Close `{OUTPUT_FILE.name}` in Excel (or any other "
                    f"program that has it open), then click **Run forecast** again.\n\n"
                    f"I stopped here instead of running the engine because the "
                    f"engine would have run for ~2 minutes and then silently "
                    f"failed at the save step — you'd have no new plan and no "
                    f"clear error."
                )
                return
            except Exception as lock_err:
                st.warning(
                    f"Could not verify write access to {OUTPUT_FILE.name} "
                    f"(`{type(lock_err).__name__}: {lock_err}`). Proceeding "
                    f"anyway, but the engine may fail at the save step."
                )

        # ---- v3.6: Back up previous plan before the engine overwrites it ----
        # Keeps the last 5 plans in data/plan_history/ so nothing is ever lost
        # (audit trail + Excel-lock-recovery).
        if OUTPUT_FILE.exists():
            try:
                hist_dir = DATA_DIR / "plan_history"
                hist_dir.mkdir(exist_ok=True)
                backup_ts = datetime.fromtimestamp(
                    OUTPUT_FILE.stat().st_mtime
                ).strftime("%Y%m%d_%H%M%S")
                backup_name = f"Polleo_Demand_Plan_{backup_ts}.xlsx"
                backup_path = hist_dir / backup_name
                if not backup_path.exists():
                    import shutil
                    shutil.copy2(OUTPUT_FILE, backup_path)
                # Prune to last 5 backups
                backups = sorted(
                    hist_dir.glob("Polleo_Demand_Plan_*.xlsx"),
                    key=lambda p: p.stat().st_mtime,
                    reverse=True,
                )
                for old in backups[5:]:
                    old.unlink(missing_ok=True)
                st.caption(f"💾 Backed up previous plan → `plan_history/{backup_name}`")
            except Exception as bk_err:
                st.warning(
                    f"Could not back up previous plan: {bk_err}. "
                    f"Proceeding anyway — engine will overwrite the current plan."
                )

        progress = st.progress(0, "Applying corrections...")
        apply_corrections_to_xlsx()

        progress.progress(10, "Starting engine...")
        log_area = st.empty()
        log_lines = []

        import time
        import copy as _copy2
        env2 = _copy2.copy(os.environ)
        env2["PYTHONIOENCODING"] = "utf-8"
        proc = subprocess.Popen(
            ["python", "-u", str(script), str(planning_book.resolve())],
            cwd=str(DATA_DIR.resolve()),
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, bufsize=1, env=env2, encoding="utf-8", errors="replace"
        )

        stage_map = {
            "Loading data": 20,
            "Cleaning": 30,
            "Samples": 40,
            "GBR MAE": 60,
            "BACKTEST": 80,
            "Building workbook": 85,
            "Saved": 95,
            "DONE": 100,
        }

        start = time.time()
        for line in proc.stdout:
            line = line.rstrip()
            if line:
                log_lines.append(line)
                log_area.code("\n".join(log_lines[-20:]))
                for keyword, pct in stage_map.items():
                    if keyword in line:
                        elapsed = int(time.time() - start)
                        progress.progress(pct, f"{line.strip()} ({elapsed}s)")
                        break

        proc.wait()
        elapsed = int(time.time() - start)

        if proc.returncode == 0:
            progress.progress(100, f"Done in {elapsed}s!")
            st.success(f"Forecast complete! ({elapsed} seconds)")
            st.balloons()
            clear_all_caches()

            # ---- Verify new plan file is actually on disk ----
            if not OUTPUT_FILE.exists():
                st.error(
                    f"⚠️ Forecast reported success but {OUTPUT_FILE.name} "
                    f"was NOT saved. Possible causes: Excel had the file open "
                    f"(Windows lock), disk full, or the engine crashed before "
                    f"the save step. Close Excel and try again."
                )
            else:
                plan_size = OUTPUT_FILE.stat().st_size
                plan_mod = datetime.fromtimestamp(OUTPUT_FILE.stat().st_mtime)
                age_sec = (datetime.now() - plan_mod).total_seconds()
                if age_sec > 60:
                    st.warning(
                        f"⚠️ {OUTPUT_FILE.name} exists ({plan_size:,} bytes) "
                        f"but was last modified {int(age_sec)}s ago — this is "
                        f"older than the run that just completed. The save "
                        f"may have failed silently. Check the engine log above "
                        f"for errors near 'Saved:'."
                    )
                else:
                    st.caption(
                        f"✅ {OUTPUT_FILE.name} saved "
                        f"({plan_size:,} bytes, "
                        f"{plan_mod.strftime('%H:%M:%S')})"
                    )

            # ---- Auto-save consensus snapshot (v3.6: errors surfaced) ----
            try:
                snap_name = save_consensus_snapshot(f"CW{cw} auto-save")
                if snap_name:
                    st.info(f"📸 Auto-saved consensus snapshot: {snap_name}")
                else:
                    st.warning(
                        "⚠️ Consensus snapshot was not created "
                        "(save_consensus_snapshot returned None). "
                        "Most likely cause: the demand plan xlsx couldn't be "
                        "read immediately after the engine saved it. Try "
                        "opening the Consensus page and manually saving a "
                        "snapshot, or re-run the forecast."
                    )
            except Exception as snap_err:
                st.error(
                    f"⚠️ Consensus snapshot failed: "
                    f"`{type(snap_err).__name__}: {snap_err}`. "
                    f"The forecast itself succeeded and the plan is on disk — "
                    f"only the snapshot wasn't captured. "
                    f"You can retry via Consensus page → Save current plan."
                )
        else:
            progress.progress(100, "Error")
            st.error(f"Forecast failed after {elapsed}s")

        st.code("\n".join(log_lines[-30:]))


def page_input(sheet_name, label, key_prefix):
    """VP or MP input page."""
    st.title(f"Demand input — {label}")
    st.caption("Enter on-top demand and regular increases per SKU per week.")

    data = load_input_sheet(sheet_name)
    if data is None:
        st.warning("Run the forecast engine first.")
        return

    cws = data["cws"]
    corrections = load_corrections()
    search = st.text_input("🔍 Search SKU", "", key=f"{key_prefix}_search")

    for rtype in ["on-top demand", "regular increase"]:
        type_rows = [r for r in data["rows"] if r["Type"] == rtype]
        if search:
            sl = search.lower()
            type_rows = [r for r in type_rows if sl in r["SKU"].lower() or sl in r["Name"].lower()]

        st.subheader(f"{rtype.title()} ({len(type_rows)} SKUs)")

        df_rows = []
        for r in type_rows:
            saved = corrections.get(key_prefix, {}).get(r["SKU"], {})
            row = {"SKU": r["SKU"], "Name": r["Name"][:35], "Cat": r["Category"]}
            for j, cw_label in enumerate(cws):
                cw_key = f"{rtype}_{cw_label.replace('CW', '')}"
                if cw_key in saved:
                    row[cw_label] = int(saved[cw_key])
                elif j < len(r["values"]):
                    row[cw_label] = int(r["values"][j])
                else:
                    row[cw_label] = 0
            df_rows.append(row)

        if not df_rows:
            st.info("No matching SKUs.")
            continue

        col_cfg = {
            "SKU": st.column_config.TextColumn("SKU", width=120, disabled=True),
            "Name": st.column_config.TextColumn("Name", width=180, disabled=True),
            "Cat": st.column_config.TextColumn("Cat", width=130, disabled=True),
        }
        for cw_label in cws:
            col_cfg[cw_label] = st.column_config.NumberColumn(
                cw_label, min_value=0, step=1, format="%d", width=70
            )

        edited = st.data_editor(
            pd.DataFrame(df_rows), column_config=col_cfg,
            use_container_width=True, hide_index=True,
            num_rows="fixed", key=f"{key_prefix}_{rtype}_ed"
        )

        if st.button(f"💾 Save {rtype}", key=f"save_{key_prefix}_{rtype}"):
            for _, row in edited.iterrows():
                sku = row["SKU"]
                vals = {}
                for cw_label in cws:
                    v = int(row[cw_label])
                    if v > 0:
                        vals[f"{rtype}_{cw_label.replace('CW', '')}"] = v
                if vals:
                    corrections.setdefault(key_prefix, {})[sku] = {
                        **corrections.get(key_prefix, {}).get(sku, {}),
                        **vals
                    }
            save_corrections(corrections)
            st.success(f"Saved {rtype}!")


def page_revenue():
    st.title("Revenue & RUC projections")

    plan = load_demand_plan()
    if plan is None:
        st.warning("Run the forecast engine first.")
        return

    sku_prices = load_sku_prices()

    cws = plan["cws"]
    rows = plan["rows"]

    # RUC per unit from sales — split by channel (wholesale vs retail margins differ)
    sc = load_sales_data()
    ruc_per_unit = {}  # sku -> {'retail': X, 'wholesale': Y, 'blended': Z}
    has_ruc_data = False
    if sc is not None and "ruc_total" in sc.columns and sc["ruc_total"].sum() > 0:
        has_ruc_data = True
        recent_yw = sc[["year", "week"]].drop_duplicates().sort_values(["year", "week"]).tail(4)
        recent_keys_int = set(recent_yw["year"].astype(int) * 100 + recent_yw["week"].astype(int))
        _yw_key = sc["year"].astype(int) * 100 + sc["week"].astype(int)
        sc_recent = sc[_yw_key.isin(recent_keys_int)]
        for sku, grp in sc_recent.groupby("sku"):
            qr = grp["qty_retail"].sum() + grp["qty_webshop"].sum()
            qw = grp["qty_wholesale"].sum()
            rr = grp.get("ruc_retail", pd.Series([0]*len(grp))).sum() + grp.get("ruc_webshop", pd.Series([0]*len(grp))).sum()
            rw = grp.get("ruc_wholesale", pd.Series([0]*len(grp))).sum()
            tq = grp["qty_total"].sum()
            tr = grp["ruc_total"].sum()
            ruc_per_unit[sku] = {
                "retail": rr / qr if qr > 0 else 0,
                "wholesale": rw / qw if qw > 0 else 0,
                "blended": tr / tq if tq > 0 and tr != 0 else 0,
            }

    def _get_rate(sku, channel="blended"):
        """Get price or RUC rate for a SKU. Channel: 'retail', 'wholesale', 'blended'."""
        if is_ruc:
            r = ruc_per_unit.get(sku, {})
            return r.get(channel, r.get("blended", 0)) if isinstance(r, dict) else r
        return sku_prices.get(sku, 0)

    # ---- Compute per-SKU wholesale share from recent history ----
    # Used to split baseline forecast into wholesale / retail channel portions
    # so "VP only" and "MP only" filters can show full channel forecast
    # (baseline + on-top), not just the on-top bars.
    ws_share_by_sku = {}
    if sc is not None and len(sc) > 0:
        recent_yw = sc[["year", "week"]].drop_duplicates().sort_values(["year", "week"]).tail(13)
        recent_keys_int = set(recent_yw["year"].astype(int) * 100 + recent_yw["week"].astype(int))
        _yw_key = sc["year"].astype(int) * 100 + sc["week"].astype(int)
        sc_recent = sc[_yw_key.isin(recent_keys_int)]
        for sku, grp in sc_recent.groupby("sku"):
            qw = float(grp["qty_wholesale"].sum())
            qr = float(grp["qty_retail"].sum()) + float(grp["qty_webshop"].sum())
            tot = qw + qr
            ws_share_by_sku[sku] = (qw / tot) if tot > 0 else 0.0

    def _ws_share(sku):
        return ws_share_by_sku.get(sku, 0.0)

    # ---- Filters ----
    from datetime import datetime as _dt
    _iso = _dt.now().isocalendar()
    _cur_y, _cur_w = int(_iso[0]), int(_iso[1])

    # Build a chronological list of (year, week) covering past actuals AND
    # forecast horizon. Past comes from sales_clean (last ~26 weeks); future
    # comes from the demand plan. This lets the user pick past months/weeks
    # in the multiselect — the chart pipeline already supports past actuals.
    past_yw = []
    if sc is not None and len(sc) > 0:
        sc_yw = (sc[["year", "week"]].drop_duplicates()
                  .sort_values(["year", "week"]).tail(26))
        past_yw = [(int(y), int(w)) for y, w in zip(sc_yw["year"], sc_yw["week"])
                    if (int(y), int(w)) < (_cur_y, _cur_w)]

    # Forecast (future) weeks from plan["cws"]. Year inference: if cw < current
    # week assume it crossed the year boundary, otherwise current year.
    future_yw = []
    for c in cws:
        w = int(str(c).replace("CW", ""))
        y = _cur_y if w >= _cur_w else _cur_y + 1
        future_yw.append((y, w))

    all_yw = past_yw + future_yw
    cw_dates_all = [_dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u") for (y, w) in all_yw]
    cw_labels_all = [f"CW{w}" for (_, w) in all_yw]
    cw_months_all = [d.strftime("%b %Y") for d in cw_dates_all]
    month_order = []
    for m in cw_months_all:
        if m not in month_order:
            month_order.append(m)
    # `cws` for the multiselect now includes past weeks too. The chart/forecast
    # pipeline keys off `cws` (future only) downstream, so keep two variables.
    cws_for_filter = cw_labels_all
    # Months mapped to the future-only `cws` list, for the downstream
    # cws_by_month intersection (which only filters forecast weeks).
    cw_months = [_dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u").strftime("%b %Y")
                  for (y, w) in future_yw]

    c1, c2, c3 = st.columns(3)
    view_mode = c1.radio("View", ["Revenue", "RUC (margin)"] if has_ruc_data else ["Revenue"],
                          horizontal=True, key="rev_mode")
    is_ruc = view_mode.startswith("RUC")

    all_cats = sorted(set(r["Category"] for r in rows if r["Category"]))
    cat_filter = c2.selectbox("Category", ["All"] + all_cats, key="rev_cat")

    source_filter = c3.selectbox("Source", ["All", "VP only", "MP only"], key="rev_source")

    c4, c5, c6 = st.columns([2, 2, 1.2])
    # Default view: last 4 past weeks + 13 forecast weeks = 17 weeks total.
    # Full history stays available in the dropdowns for retro-analysis.
    _default_past = past_yw[-4:] if len(past_yw) >= 4 else past_yw
    _default_yw = _default_past + future_yw
    _default_months = []
    for (y, w) in _default_yw:
        m = _dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u").strftime("%b %Y")
        if m not in _default_months:
            _default_months.append(m)
    month_sel = c4.multiselect("Months", month_order, default=_default_months, key="rev_month")

    # Restrict Weeks options to weeks that fall inside the selected months,
    # so e.g. picking only May shows only May's CWs.
    _selected_yw = [(y, w) for (y, w) in all_yw
                     if _dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u").strftime("%b %Y") in month_sel]
    valid_weeks = [f"CW{w}" for (_, w) in _selected_yw]

    # When the month selection changes, reset week_sel default to "all weeks
    # in the chosen months" — otherwise Streamlit would keep stale picks
    # (or drop the ones no longer valid, leaving an empty filter).
    _prev = st.session_state.get("_rev_prev_months")
    if _prev != tuple(month_sel):
        st.session_state["rev_week"] = valid_weeks
        st.session_state["_rev_prev_months"] = tuple(month_sel)

    week_sel = c5.multiselect("Weeks", valid_weeks, default=valid_weeks, key="rev_week")
    # Gross-up toggle: include non-planned SKUs (those not in sku_plan_list).
    # Works with any source_filter — we compute a separate wholesale vs retail
    # gross-up ratio so VP-only and MP-only views include their channel share of
    # non-planned SKUs. Still disabled when a category is selected, because
    # non-planned items lack reliable category attribution.
    can_grossup = (cat_filter == "All" and sc is not None)
    include_nonplanned = c6.checkbox(
        "Include non-planned SKUs",
        value=can_grossup,
        disabled=not can_grossup,
        key="rev_inc_nonplanned",
        help="Past: real revenue across ALL SKUs. Forecast: planned × channel-specific gross-up ratio.",
    ) if can_grossup else False

    # Intersect month and week filters for the FUTURE forecast weeks.
    # Empty intersection is intentional — e.g. when only a past month is
    # selected, no forecast weeks should be drawn. Past actuals are gated
    # separately by month_sel + week_sel further down.
    cws_by_month = set(c for c, m in zip(cws, cw_months) if m in month_sel) if month_sel else set()
    cws_by_week = set(week_sel) if week_sel else set()
    active_set = cws_by_month & cws_by_week
    week_mask = [c in active_set for c in cws]

    # Load VP/MP inputs
    inputs = load_vp_mp_inputs()

    # ---- Compute per-category per-week values ----
    filtered_rows = rows if cat_filter == "All" else [r for r in rows if r["Category"] == cat_filter]

    cat_vals = {}          # baseline blended (legacy, used for "All" display)
    cat_vals_retail = {}   # baseline retail portion
    cat_vals_ws = {}       # baseline wholesale portion
    cat_vp = {}
    cat_mp = {}
    for r in filtered_rows:
        cat = r["Category"] or "OTHER"
        sku = r["SKU"]
        rate_bl = _get_rate(sku, "blended")
        rate_r = _get_rate(sku, "retail")
        rate_w = _get_rate(sku, "wholesale")
        wshare = _ws_share(sku)
        if cat not in cat_vals:
            cat_vals[cat] = [0.0] * len(cws)
            cat_vals_retail[cat] = [0.0] * len(cws)
            cat_vals_ws[cat] = [0.0] * len(cws)
            cat_vp[cat] = [0.0] * len(cws)
            cat_mp[cat] = [0.0] * len(cws)
        for j in range(min(len(cws), len(r["baseline"]))):
            f = r["factors"][j] if j < len(r["factors"]) else 1.0
            units = r["baseline"][j] * f
            cat_vals[cat][j] += units * rate_bl
            cat_vals_ws[cat][j] += units * wshare * rate_w
            cat_vals_retail[cat][j] += units * (1 - wshare) * rate_r

    # VP contribution — uses WHOLESALE margin rate
    if inputs["vp"] is not None and cws:
        filtered_skus = set(r["SKU"] for r in filtered_rows)
        sku_inp = inputs["vp"][inputs["vp"]["sku"].isin(filtered_skus)]
        for _, inp_row in sku_inp.iterrows():
            sku = inp_row["sku"]
            cat = next((r["Category"] for r in rows if r["SKU"] == sku), "OTHER") or "OTHER"
            rate = _get_rate(sku, "wholesale")
            if cat not in cat_vp:
                cat_vp[cat] = [0.0] * len(cws)
            for j, cw_label in enumerate(cws):
                v = float(inp_row.get(cw_label, 0) or 0)
                cat_vp[cat][j] += v * rate

    # MP contribution — uses RETAIL margin rate
    if inputs["mp"] is not None and cws:
        filtered_skus = set(r["SKU"] for r in filtered_rows)
        sku_inp = inputs["mp"][inputs["mp"]["sku"].isin(filtered_skus)]
        for _, inp_row in sku_inp.iterrows():
            sku = inp_row["sku"]
            cat = next((r["Category"] for r in rows if r["SKU"] == sku), "OTHER") or "OTHER"
            rate = _get_rate(sku, "retail")
            if cat not in cat_mp:
                cat_mp[cat] = [0.0] * len(cws)
            for j, cw_label in enumerate(cws):
                v = float(inp_row.get(cw_label, 0) or 0)
                cat_mp[cat][j] += v * rate

    # Totals
    total_base = [sum(cat_vals[c][j] for c in cat_vals) for j in range(len(cws))]
    total_base_ws = [sum(cat_vals_ws[c][j] for c in cat_vals_ws) for j in range(len(cws))]
    total_base_retail = [sum(cat_vals_retail[c][j] for c in cat_vals_retail) for j in range(len(cws))]
    total_vp = [sum(cat_vp.get(c, [0]*len(cws))[j] for c in cat_vals) for j in range(len(cws))]
    total_mp = [sum(cat_mp.get(c, [0]*len(cws))[j] for c in cat_vals) for j in range(len(cws))]
    total_all = [total_base[j] + total_vp[j] + total_mp[j] for j in range(len(cws))]

    # Display line based on source filter.
    # "VP only" = wholesale channel forecast (baseline ws portion + VP on-tops)
    # "MP only" = retail channel forecast (baseline retail portion + MP on-tops)
    if source_filter == "VP only":
        display_line = [total_base_ws[j] + total_vp[j] for j in range(len(cws))]
        line_label = "Wholesale forecast (baseline + VP)"
    elif source_filter == "MP only":
        display_line = [total_base_retail[j] + total_mp[j] for j in range(len(cws))]
        line_label = "Retail forecast (baseline + MP)"
    else:
        display_line = total_all
        line_label = f"Total {'RUC' if is_ruc else 'Revenue'}"

    # Apply week mask for the filtered totals & chart
    cws_f = [c for c, keep in zip(cws, week_mask) if keep]
    display_line_f = [v for v, keep in zip(display_line, week_mask) if keep]
    total_vp_f = [v for v, keep in zip(total_vp, week_mask) if keep]
    total_mp_f = [v for v, keep in zip(total_mp, week_mask) if keep]
    total_base_ws_f = [v for v, keep in zip(total_base_ws, week_mask) if keep]
    total_base_retail_f = [v for v, keep in zip(total_base_retail, week_mask) if keep]

    # ---- Gross-up ratios (per channel) for non-planned SKUs ----
    # Compute three ratios from the last ~13 weeks of history:
    #   gross_up_ws     = total_wholesale_all / total_wholesale_planned
    #   gross_up_retail = total_retail_all / total_retail_planned
    #   gross_up        = blended (for source=All)
    # The applied multiplier is picked based on source_filter.
    gross_up_ws, gross_up_retail, gross_up = 1.0, 1.0, 1.0
    nonplan_share_ws_pct = nonplan_share_retail_pct = nonplan_share_all_pct = 0.0
    if include_nonplanned and sc is not None:
        planned_set = set(r["SKU"] for r in rows)
        recent_yw = sc[["year", "week"]].drop_duplicates().sort_values(["year", "week"]).tail(13)
        recent_keys_int = set(recent_yw["year"].astype(int) * 100 + recent_yw["week"].astype(int))
        _yw_key = sc["year"].astype(int) * 100 + sc["week"].astype(int)
        sc_recent = sc[_yw_key.isin(recent_keys_int)].copy()
        plan_mask = sc_recent["sku"].isin(planned_set)
        if is_ruc:
            ws_all = float(sc_recent["ruc_wholesale"].sum())
            ws_plan = float(sc_recent[plan_mask]["ruc_wholesale"].sum())
            rt_all = float(sc_recent["ruc_retail"].sum() + sc_recent["ruc_webshop"].sum())
            rt_plan = float(sc_recent[plan_mask]["ruc_retail"].sum() + sc_recent[plan_mask]["ruc_webshop"].sum())
        else:
            ws_all = ws_plan = rt_all = rt_plan = 0.0
            for _, rr in sc_recent.iterrows():
                price = sku_prices.get(rr["sku"], 0)
                ws = float(rr["qty_wholesale"]) * price
                rt = (float(rr["qty_retail"]) + float(rr["qty_webshop"])) * price
                ws_all += ws
                rt_all += rt
                if rr["sku"] in planned_set:
                    ws_plan += ws
                    rt_plan += rt
        if ws_plan > 0 and ws_all > ws_plan:
            gross_up_ws = ws_all / ws_plan
            nonplan_share_ws_pct = (1 - ws_plan / ws_all) * 100
        if rt_plan > 0 and rt_all > rt_plan:
            gross_up_retail = rt_all / rt_plan
            nonplan_share_retail_pct = (1 - rt_plan / rt_all) * 100
        total_all = ws_all + rt_all
        total_plan = ws_plan + rt_plan
        if total_plan > 0 and total_all > total_plan:
            gross_up = total_all / total_plan
            nonplan_share_all_pct = (1 - total_plan / total_all) * 100

    # Effective gross-up for the current source_filter view
    if source_filter == "VP only":
        effective_gu = gross_up_ws
        effective_share_pct = nonplan_share_ws_pct
    elif source_filter == "MP only":
        effective_gu = gross_up_retail
        effective_share_pct = nonplan_share_retail_pct
    else:
        effective_gu = gross_up
        effective_share_pct = nonplan_share_all_pct

    # ---- Actuals for past weeks in selected months ----
    # If the user picked months that include weeks already behind us, append
    # realised revenue/RUC from sales_clean so the chart shows actuals + forecast.
    past_cws, past_actuals = [], []
    if sc is not None and month_sel:
        # When gross-up is on, past actuals include ALL SKUs (not just planned).
        if include_nonplanned:
            sc_f = sc.copy()
        else:
            filtered_skus = set(r["SKU"] for r in filtered_rows)
            sc_f = sc[sc["sku"].isin(filtered_skus)].copy()
        sc_f["_y"] = sc_f["year"].astype(int)
        sc_f["_w"] = sc_f["week"].astype(int)
        past_rows = sc_f.groupby(["_y", "_w"], as_index=False).agg({
            "qty_retail": "sum", "qty_webshop": "sum", "qty_wholesale": "sum",
            "qty_total": "sum",
            "ruc_retail": "sum", "ruc_webshop": "sum", "ruc_wholesale": "sum", "ruc_total": "sum",
        })
        for _, r in past_rows.iterrows():
            y, w = int(r["_y"]), int(r["_w"])
            # Strictly past weeks only (exclude current ongoing week)
            if (y, w) >= (_cur_y, _cur_w):
                continue
            try:
                d = _dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u")
            except ValueError:
                continue
            m = d.strftime("%b %Y")
            if m not in month_sel:
                continue
            # Also honour week-level filter (so deselecting a single CW inside
            # a selected month removes its bar from the chart).
            if week_sel and f"CW{w}" not in week_sel:
                continue
            # Compute value per source_filter + view_mode
            if is_ruc:
                if source_filter == "VP only":
                    val = float(r["ruc_wholesale"])
                elif source_filter == "MP only":
                    val = float(r["ruc_retail"]) + float(r["ruc_webshop"])
                else:
                    val = float(r["ruc_total"])
            else:
                # Revenue: recompute per SKU so we use actual prices, not aggregates
                wk_skus = sc_f[(sc_f["_y"] == y) & (sc_f["_w"] == w)]
                val = 0.0
                for _, sr in wk_skus.iterrows():
                    sku = sr["sku"]
                    price = sku_prices.get(sku, 0)
                    if source_filter == "VP only":
                        val += float(sr["qty_wholesale"]) * price
                    elif source_filter == "MP only":
                        val += (float(sr["qty_retail"]) + float(sr["qty_webshop"])) * price
                    else:
                        val += float(sr["qty_total"]) * price
            past_cws.append((d, f"CW{w}", val))

    past_cws.sort(key=lambda t: t[0])
    past_labels = [t[1] for t in past_cws]
    past_values = [t[2] for t in past_cws]

    # Apply gross-up to forecast line only. On-top bars (VP/MP) are literal
    # planner commitments and must NOT be scaled. Use channel-specific ratio
    # so VP-only and MP-only views also reflect non-planned SKUs.
    if effective_gu != 1.0:
        display_line_f = [v * effective_gu for v in display_line_f]

    # ---- Current-week forecast ----
    # Today's run sets CW_START = current_week + 1, so the live xlsx has no
    # column for the current week. Look it up in plan_history/ — last week's
    # run had it as a future week with a real forecast. Falls back to the
    # first-future-week proxy if no archive is found.
    cur_label, cur_value = None, None
    try:
        _cur_date = _dt.strptime(f"{_cur_y}-W{_cur_w:02d}-1", "%G-W%V-%u")
        _cur_month = _cur_date.strftime("%b %Y")
        _cur_cw_label = f"CW{_cur_w}"
        if (_cur_month in month_sel
                and (not week_sel or _cur_cw_label in week_sel)):
            cur_label = _cur_cw_label
            past_fc_units = _load_past_forecast_total_for_cw(_cur_w)
            if past_fc_units:
                # Build cur_value from per-SKU units in the archived plan,
                # respecting category + source filters and view mode.
                v = 0.0
                for r in filtered_rows:
                    sku = r["SKU"]
                    units = float(past_fc_units.get(sku, 0))
                    if units <= 0:
                        continue
                    rate = _get_rate(sku, "blended")
                    if source_filter == "VP only":
                        units = units * _ws_share(sku)
                        rate = _get_rate(sku, "wholesale")
                    elif source_filter == "MP only":
                        units = units * (1 - _ws_share(sku))
                        rate = _get_rate(sku, "retail")
                    v += units * rate
                cur_value = v * effective_gu
            elif len(display_line) > 0:
                cur_value = float(display_line[0]) * effective_gu
    except ValueError:
        pass

    total_13wk = sum(display_line_f) + sum(past_values) + (cur_value or 0.0)
    unit = "RUC" if is_ruc else "Revenue"

    # ---- Metrics ----
    extra = 1 if cur_value is not None else 0
    total_weeks_shown = len(cws_f) + len(past_cws) + extra
    n_weeks_f = max(total_weeks_shown, 1)
    c1m, c2m, c3m = st.columns(3)
    _help = None
    if past_cws or extra:
        _help = f"{len(past_cws)} actual + {extra} current + {len(cws_f)} forecast weeks"
    c1m.metric(f"{total_weeks_shown}-week {unit}", f"\u20ac{total_13wk:,.0f}", help=_help)
    c2m.metric("Avg weekly", f"\u20ac{total_13wk / n_weeks_f:,.0f}")
    if include_nonplanned and effective_gu > 1.0:
        _ch_label = {"VP only": "Wholesale", "MP only": "Retail+Webshop"}.get(source_filter, "All channels")
        st.caption(
            f"{_ch_label}: non-planned SKUs contribute ~{effective_share_pct:.1f}% of recent {unit.lower()} "
            f"(gross-up ×{effective_gu:.2f} applied to forecast line only; VP/MP on-top bars unchanged)."
        )
    elif include_nonplanned and effective_gu == 1.0:
        st.caption("No non-planned SKUs detected in recent history for this channel — gross-up is 1.00x.")
    if is_ruc and sum(total_base) > 0:
        # Revenue denominator that MIRRORS total_13wk scope:
        # past actuals + current-week proxy + forecast weeks, same source_filter,
        # same SKU set (planned-only for forecast, all SKUs for past if gross-up on),
        # and the same channel-specific gross-up applied to forecast weeks.

        # --- Forecast-week revenue (baseline + VP + MP on-tops) ---
        rev_fcst_base = 0.0
        for r in filtered_rows:
            price = sku_prices.get(r["SKU"], 0)
            wshare = _ws_share(r["SKU"])
            for j in range(len(cws)):
                if not week_mask[j]:
                    continue
                f = r["factors"][j] if j < len(r["factors"]) else 1.0
                units = r["baseline"][j] * f
                if source_filter == "VP only":
                    rev_fcst_base += units * wshare * price
                elif source_filter == "MP only":
                    rev_fcst_base += units * (1 - wshare) * price
                else:
                    rev_fcst_base += units * price
        rev_fcst_base *= effective_gu  # match RUC line treatment

        rev_fcst_ontop = 0.0
        filtered_skus = set(r["SKU"] for r in filtered_rows)
        if source_filter != "MP only" and inputs["vp"] is not None:
            sku_inp = inputs["vp"][inputs["vp"]["sku"].isin(filtered_skus)]
            for _, inp_row in sku_inp.iterrows():
                price = sku_prices.get(inp_row["sku"], 0)
                for j, cw_label in enumerate(cws):
                    if not week_mask[j]:
                        continue
                    rev_fcst_ontop += float(inp_row.get(cw_label, 0) or 0) * price
        if source_filter != "VP only" and inputs["mp"] is not None:
            sku_inp = inputs["mp"][inputs["mp"]["sku"].isin(filtered_skus)]
            for _, inp_row in sku_inp.iterrows():
                price = sku_prices.get(inp_row["sku"], 0)
                for j, cw_label in enumerate(cws):
                    if not week_mask[j]:
                        continue
                    rev_fcst_ontop += float(inp_row.get(cw_label, 0) or 0) * price

        rev_fcst = rev_fcst_base + rev_fcst_ontop

        # --- Past revenue from sales_clean (same weeks as past_cws) ---
        rev_past = 0.0
        if sc is not None and month_sel:
            if include_nonplanned:
                sc_f_rev = sc.copy()
            else:
                sc_f_rev = sc[sc["sku"].isin(set(r["SKU"] for r in filtered_rows))].copy()
            sc_f_rev["_y"] = sc_f_rev["year"].astype(int)
            sc_f_rev["_w"] = sc_f_rev["week"].astype(int)
            for _, sr in sc_f_rev.iterrows():
                y, w = int(sr["_y"]), int(sr["_w"])
                if (y, w) >= (_cur_y, _cur_w):
                    continue
                try:
                    d = _dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u")
                except ValueError:
                    continue
                if d.strftime("%b %Y") not in month_sel:
                    continue
                price = sku_prices.get(sr["sku"], 0)
                if source_filter == "VP only":
                    rev_past += float(sr["qty_wholesale"]) * price
                elif source_filter == "MP only":
                    rev_past += (float(sr["qty_retail"]) + float(sr["qty_webshop"])) * price
                else:
                    rev_past += float(sr["qty_total"]) * price

        # --- Current-week proxy (first forecast week's revenue × effective_gu) ---
        rev_cur = 0.0
        if cur_value is not None and len(cws) > 0:
            first_rev = 0.0
            for r in filtered_rows:
                price = sku_prices.get(r["SKU"], 0)
                wshare = _ws_share(r["SKU"])
                f = r["factors"][0] if len(r["factors"]) > 0 else 1.0
                units = r["baseline"][0] * f
                if source_filter == "VP only":
                    first_rev += units * wshare * price
                elif source_filter == "MP only":
                    first_rev += units * (1 - wshare) * price
                else:
                    first_rev += units * price
            rev_cur = first_rev * effective_gu

        rev_total = rev_fcst + rev_past + rev_cur
        c3m.metric("Margin %", f"{total_13wk / max(rev_total, 1) * 100:.1f}%")
    else:
        c3m.metric("Categories", len(cat_vals))

    # ---- Chart: line + VP/MP bars ----
    fig = go.Figure()

    # Combined x-axis: actuals first, then current (if applicable), then forecast
    cur_x = [cur_label] if cur_value is not None else []
    combined_x = past_labels + cur_x + cws_f
    combined_y_forecast = (
        [None] * len(past_labels)
        + ([round(cur_value)] if cur_value is not None else [])
        + [round(v) for v in display_line_f]
    )
    combined_y_actual = (
        [round(v) for v in past_values]
        + [None] * len(cur_x)
        + [None] * len(cws_f)
    )
    # Bridge the visual gap between actuals and forecast: duplicate the last
    # actual value into the forecast series at that x so the forecast line
    # starts from the same point. Suppress the extra label to avoid overlap.
    _bridge_idx = None
    if past_labels and (cur_x or cws_f):
        _bridge_idx = len(past_labels) - 1
        combined_y_forecast[_bridge_idx] = combined_y_actual[_bridge_idx]

    # Actuals (solid, darker)
    if past_labels:
        fig.add_trace(go.Scatter(
            x=combined_x, y=combined_y_actual,
            mode="lines+markers+text",
            line=dict(color="#444444", width=2.5),
            marker=dict(size=7, symbol="square"),
            text=[f"{int(v):,}" if v is not None else "" for v in combined_y_actual],
            textposition="top center", textfont=dict(size=9),
            name=f"Actual {unit}",
            connectgaps=False,
        ))

    # Forecast line
    _fc_text = []
    for i, v in enumerate(combined_y_forecast):
        if v is None or i == _bridge_idx:
            _fc_text.append("")
        else:
            _fc_text.append(f"{int(v):,}")
    fig.add_trace(go.Scatter(
        x=combined_x, y=combined_y_forecast,
        mode="lines+markers+text",
        line=dict(color="#2F5496" if not is_ruc else "#006600", width=2.5, dash="dot" if past_labels else "solid"),
        marker=dict(size=6),
        text=_fc_text,
        textposition="top center", textfont=dict(size=9),
        name=line_label,
        connectgaps=False,
    ))

    # VP bars (align with combined x axis — blank over past + current proxy weeks)
    _pad = [0] * (len(past_labels) + len(cur_x))
    _pad_txt = [""] * (len(past_labels) + len(cur_x))
    if source_filter in ("All", "VP only") and any(v > 0 for v in total_vp_f):
        fig.add_trace(go.Bar(
            x=combined_x, y=_pad + [round(v) for v in total_vp_f], name=f"VP on-top {unit}",
            marker_color="rgba(47, 84, 150, 0.45)",
            text=_pad_txt + [f"{int(v):,}" if v > 50 else "" for v in total_vp_f],
            textposition="outside", textfont=dict(size=8), width=0.4,
        ))

    # MP bars
    if source_filter in ("All", "MP only") and any(v > 0 for v in total_mp_f):
        fig.add_trace(go.Bar(
            x=combined_x, y=_pad + [round(v) for v in total_mp_f], name=f"MP on-top {unit}",
            marker_color="rgba(255, 107, 53, 0.45)",
            text=_pad_txt + [f"{int(v):,}" if v > 50 else "" for v in total_mp_f],
            textposition="outside", textfont=dict(size=8), width=0.4,
        ))

    fig.update_layout(
        barmode="overlay", height=420,
        yaxis=dict(title=f"{unit} (\u20ac)", tickformat=","),
        margin=dict(l=60, r=20, t=30, b=40),
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
        legend=dict(orientation="h", y=1.05),
    )
    fig.update_xaxes(showgrid=False)
    fig.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)")
    st.plotly_chart(fig, use_container_width=True)

    # ---- Category breakdown table ----
    def _sum_filtered(arr):
        return sum(v for v, keep in zip(arr, week_mask) if keep)

    st.subheader(f"{unit} by category ({len(cws_f)} weeks)")
    cat_table = []
    for cat in sorted(cat_vals.keys()):
        base_total = _sum_filtered(cat_vals[cat])
        base_ws = _sum_filtered(cat_vals_ws.get(cat, [0]*len(cws)))
        base_rt = _sum_filtered(cat_vals_retail.get(cat, [0]*len(cws)))
        vp_total = _sum_filtered(cat_vp.get(cat, [0]*len(cws)))
        mp_total = _sum_filtered(cat_mp.get(cat, [0]*len(cws)))
        if source_filter == "VP only":
            row_total = base_ws + vp_total
        elif source_filter == "MP only":
            row_total = base_rt + mp_total
        else:
            row_total = base_total + vp_total + mp_total
        cat_table.append({
            "Category": cat,
            f"Baseline {unit}": round(base_total),
            f"VP on-top {unit}": round(vp_total),
            f"MP on-top {unit}": round(mp_total),
            f"Shown {unit}": round(row_total),
        })
    if cat_table:
        cdf = pd.DataFrame(cat_table).sort_values(f"Shown {unit}", ascending=False)
        st.dataframe(cdf, use_container_width=True, hide_index=True)

    # ---- Revenue bridge ----
    snapshots = load_consensus_snapshots()
    if snapshots:
        st.divider()
        st.subheader(f"{unit} bridge vs. last snapshot")
        bridge = compute_revenue_bridge(plan, snapshots)
        if bridge:
            fig_w = go.Figure(go.Waterfall(
                name="Bridge", orientation="v",
                x=[bridge["prev_label"], "Volume", "VP/MP inputs", "New SKUs", "Discontinued", "This cycle"],
                y=[bridge["prev_total"], bridge["volume_change"], bridge["vp_mp"],
                   bridge["new_skus"], -bridge["removed"], bridge["curr_total"]],
                measure=["absolute", "relative", "relative", "relative", "relative", "total"],
                connector=dict(line=dict(color="rgba(200,200,200,0.4)")),
                textposition="outside",
                text=[f"\u20ac{bridge['prev_total']:,}", f"\u20ac{bridge['volume_change']:+,}",
                      f"\u20ac{bridge['vp_mp']:+,}", f"\u20ac{bridge['new_skus']:+,}",
                      f"\u20ac{-bridge['removed']:+,}", f"\u20ac{bridge['curr_total']:,}"],
                increasing=dict(marker_color="#97C459"),
                decreasing=dict(marker_color="#F09595"),
                totals=dict(marker_color="#85B7EB"),
            ))
            fig_w.update_layout(height=350, margin=dict(l=50, r=20, t=30, b=40),
                plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
                yaxis=dict(tickformat=",", title=f"{unit} \u20ac"), showlegend=False)
            fig_w.update_xaxes(showgrid=False)
            fig_w.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)")
            st.plotly_chart(fig_w, use_container_width=True)
            delta = bridge["curr_total"] - bridge["prev_total"]
            pct = delta / max(bridge["prev_total"], 1) * 100
            st.caption(f"Total change: \u20ac{delta:+,} ({pct:+.1f}%)")
        else:
            st.info("Cannot compute bridge \u2014 snapshot data incomplete.")
    else:
        st.caption("Save a consensus snapshot to enable the revenue bridge chart.")

def page_sku_management():
    st.title("SKU list management")
    st.caption("Add, remove, or reclassify planned SKUs. Changes take effect on next forecast run.")

    PLAN_FILE = DATA_DIR / "sku_plan_list.csv"

    # ---- Initialize from Planning Book if no plan list yet ----
    if not PLAN_FILE.exists():
        planning_books = list(DATA_DIR.glob("Polleo_Demand_Planning_Book*.xlsx"))
        if planning_books:
            if st.button("Initialize SKU list from Planning Book", type="primary"):
                from openpyxl import load_workbook as lw
                wb = lw(planning_books[0], data_only=True)
                ws = wb["Run Rate"]
                rows = []
                pr = {}
                if "cijena po artiklu" in wb.sheetnames:
                    ws_p = wb["cijena po artiklu"]
                    for r in range(2, ws_p.max_row+1):
                        s = ws_p.cell(r,1).value
                        if s: pr[s] = float(ws_p.cell(r,5).value or 0)
                for r in range(4, ws.max_row+1):
                    sku = ws.cell(r,1).value
                    if not sku: continue
                    rows.append({"sku": sku, "name": ws.cell(r,2).value or "",
                        "cat": ws.cell(r,3).value or "",
                        "oznaka": ws.cell(r,4).value or "",
                        "vpc": pr.get(sku, 0)})
                wb.close()
                pd.DataFrame(rows).to_csv(PLAN_FILE, index=False)
                st.success(f"Created SKU list: {len(rows)} SKUs")
                st.rerun()
        else:
            st.error("No Planning Book or sku_plan_list.csv found.")
        return

    # ---- Load current plan list ----
    plan = pd.read_csv(PLAN_FILE)

    # Summary metrics
    c1, c2, c3, c4 = st.columns(4)
    c1.metric("Total SKUs", len(plan))
    for ozn in OZNAKA_TIERS:
        n = len(plan[plan["oznaka"] == ozn])
        {"01 GOLD": c2, "02 SILVER": c3, "03 BRONZE": c4}[ozn].metric(ozn, n)

    st.divider()

    # ---- TABS for different actions ----
    tab_view, tab_edit, tab_add, tab_remove = st.tabs([
        "📋 View list", "✏️ Edit OZNAKA", "➕ Add SKUs", "🗑️ Remove SKUs"
    ])

    # ---- VIEW TAB ----
    with tab_view:
        c1, c2, c3 = st.columns([2, 1, 1])
        search = c1.text_input("Search", "", key="sku_mgmt_search")
        cat_filt = c2.selectbox("Category", ["All"] + sorted(plan["cat"].unique().tolist()), key="sku_mgmt_cat")
        ozn_filt = c3.selectbox("OZNAKA", ["All"] + OZNAKA_TIERS, key="sku_mgmt_ozn")

        view = plan.copy()
        if search:
            sl = search.lower()
            view = view[view["sku"].str.lower().str.contains(sl) | view["name"].str.lower().str.contains(sl)]
        if cat_filt != "All":
            view = view[view["cat"] == cat_filt]
        if ozn_filt != "All":
            view = view[view["oznaka"] == ozn_filt]

        st.dataframe(
            view[["sku", "name", "cat", "oznaka"]].rename(
                columns={"sku": "SKU", "name": "Name", "cat": "Category", "oznaka": "OZNAKA"}),
            use_container_width=True, hide_index=True
        )
        st.caption(f"Showing {len(view)} of {len(plan)} SKUs")

    # ---- EDIT OZNAKA TAB ----
    with tab_edit:
        st.caption("Change gold/silver/bronze classification. Filter first, then edit the OZNAKA column.")

        c1, c2 = st.columns([2, 1])
        edit_search = c1.text_input("Search SKU or name", "", key="edit_search")
        edit_cat = c2.selectbox("Category", ["All"] + sorted(plan["cat"].unique().tolist()), key="edit_cat")

        edit_df = plan[["sku", "name", "cat", "oznaka"]].copy()
        if edit_search:
            sl = edit_search.lower()
            edit_df = edit_df[edit_df["sku"].str.lower().str.contains(sl) | edit_df["name"].str.lower().str.contains(sl)]
        if edit_cat != "All":
            edit_df = edit_df[edit_df["cat"] == edit_cat]

        if len(edit_df) == 0:
            st.info("No SKUs match.")
        else:
            col_config = {
                "sku": st.column_config.TextColumn("SKU", width=120, disabled=True),
                "name": st.column_config.TextColumn("Name", width=250, disabled=True),
                "cat": st.column_config.TextColumn("Category", width=160, disabled=True),
                "oznaka": st.column_config.SelectboxColumn("OZNAKA", width=120,
                    options=OZNAKA_TIERS),
            }
            edited = st.data_editor(edit_df, column_config=col_config,
                use_container_width=True, hide_index=True, num_rows="fixed",
                key="oznaka_editor")

            if st.button("💾 Save OZNAKA changes", type="primary", key="save_oznaka"):
                changes = 0
                for _, row in edited.iterrows():
                    mask = plan["sku"] == row["sku"]
                    if plan.loc[mask, "oznaka"].values[0] != row["oznaka"]:
                        plan.loc[mask, "oznaka"] = row["oznaka"]
                        changes += 1
                plan.to_csv(PLAN_FILE, index=False)
                st.success(f"Saved! {changes} SKU(s) reclassified.")
                st.rerun()

    # ---- ADD SKUs TAB ----
    with tab_add:
        st.caption("Add SKUs from the full portfolio (9,600+ SKUs in sales data).")

        cat_map_path = DATA_DIR / "sku_category_map.csv"
        if not cat_map_path.exists():
            st.warning("sku_category_map.csv not found — upload it first.")
        else:
            all_skus = pd.read_csv(cat_map_path)
            current_skus = set(plan["sku"])
            available = all_skus[~all_skus["sku"].isin(current_skus)].copy()

            sc_path = DATA_DIR / "sales_clean.csv"
            if sc_path.exists():
                sc = pd.read_csv(sc_path)
                last_12 = sc.sort_values(["year", "week"]).groupby("sku").tail(12)
                vol = last_12.groupby("sku")["qty_total"].sum().reset_index()
                vol.columns = ["sku", "last_12wk_qty"]
                available = available.merge(vol, on="sku", how="left")
                available["last_12wk_qty"] = available["last_12wk_qty"].fillna(0).astype(int)
                available = available.sort_values("last_12wk_qty", ascending=False)

            st.write(f"**{len(available):,}** SKUs available to add")

            c1, c2 = st.columns([2, 1])
            add_search = c1.text_input("Search by SKU or name", "", key="add_search")
            add_cat = c2.selectbox("Category", ["All"] + sorted(available["cat"].dropna().unique().tolist()), key="add_cat")

            filtered = available.copy()
            if add_search:
                sl = add_search.lower()
                filtered = filtered[filtered["sku"].str.lower().str.contains(sl, na=False) |
                                    filtered["name"].str.lower().str.contains(sl, na=False)]
            if add_cat != "All":
                filtered = filtered[filtered["cat"] == add_cat]

            show = filtered.head(50).copy()
            if len(show) == 0:
                st.info("No matching SKUs.")
            else:
                show["add"] = False
                show["oznaka"] = "03 BRONZE"

                display_cols = ["add", "sku", "name", "cat", "oznaka"]
                if "last_12wk_qty" in show.columns:
                    display_cols.insert(4, "last_12wk_qty")

                col_config = {
                    "add": st.column_config.CheckboxColumn("Add?", width=60),
                    "sku": st.column_config.TextColumn("SKU", width=120, disabled=True),
                    "name": st.column_config.TextColumn("Name", width=250, disabled=True),
                    "cat": st.column_config.TextColumn("Category", width=150, disabled=True),
                    "oznaka": st.column_config.SelectboxColumn("OZNAKA", width=120,
                        options=OZNAKA_TIERS),
                }
                if "last_12wk_qty" in show.columns:
                    col_config["last_12wk_qty"] = st.column_config.NumberColumn(
                        "12wk volume", width=100, disabled=True)

                edited = st.data_editor(show[display_cols], column_config=col_config,
                    use_container_width=True, hide_index=True, num_rows="fixed",
                    key="add_editor")

                to_add = edited[edited["add"] == True]
                if len(to_add) > 0:
                    st.write(f"**{len(to_add)}** SKU(s) selected to add")

                if st.button(f"➕ Add {len(to_add)} SKU(s) to plan", type="primary",
                             key="do_add", disabled=len(to_add)==0):
                    new_rows = []
                    for _, row in to_add.iterrows():
                        info = all_skus[all_skus["sku"] == row["sku"]].iloc[0]
                        new_rows.append({
                            "sku": row["sku"],
                            "name": info.get("name", ""),
                            "cat": info.get("cat", ""),
                            "oznaka": row["oznaka"],
                            "vpc": 0
                        })
                    updated = pd.concat([plan, pd.DataFrame(new_rows)], ignore_index=True)
                    updated.to_csv(PLAN_FILE, index=False)
                    st.success(f"Added {len(new_rows)} SKU(s)! Total: {len(updated)}")
                    st.rerun()

    # ---- REMOVE SKUs TAB ----
    with tab_remove:
        st.caption("Remove SKUs from the planned list. They stay in the portfolio forecast.")

        c1, c2 = st.columns([2, 1])
        rm_search = c1.text_input("Search SKU or name", "", key="rm_search")
        rm_cat = c2.selectbox("Category", ["All"] + sorted(plan["cat"].unique().tolist()), key="rm_cat")

        rm_df = plan[["sku", "name", "cat", "oznaka"]].copy()
        if rm_search:
            sl = rm_search.lower()
            rm_df = rm_df[rm_df["sku"].str.lower().str.contains(sl) | rm_df["name"].str.lower().str.contains(sl)]
        if rm_cat != "All":
            rm_df = rm_df[rm_df["cat"] == rm_cat]

        if len(rm_df) == 0:
            st.info("No matching SKUs.")
        else:
            rm_df["remove"] = False
            col_config = {
                "remove": st.column_config.CheckboxColumn("Remove?", width=80),
                "sku": st.column_config.TextColumn("SKU", width=120, disabled=True),
                "name": st.column_config.TextColumn("Name", width=250, disabled=True),
                "cat": st.column_config.TextColumn("Category", width=160, disabled=True),
                "oznaka": st.column_config.TextColumn("OZNAKA", width=110, disabled=True),
            }
            edited = st.data_editor(rm_df, column_config=col_config,
                use_container_width=True, hide_index=True, num_rows="fixed",
                key="remove_editor")

            to_remove = edited[edited["remove"] == True]
            if len(to_remove) > 0:
                st.warning(f"⚠️ {len(to_remove)} SKU(s) selected for removal")

            if st.button(f"🗑️ Remove {len(to_remove)} SKU(s)", type="primary",
                         key="do_remove", disabled=len(to_remove)==0):
                remove_skus = set(to_remove["sku"])
                updated = plan[~plan["sku"].isin(remove_skus)]
                updated.to_csv(PLAN_FILE, index=False)
                st.success(f"Removed {len(remove_skus)} SKU(s). Remaining: {len(updated)}")
                st.rerun()


def _wipe_slack_cycle() -> int:
    """Remove the active Slack cycle and its collected responses so the
    next Distribute starts fresh. Returns count of files removed."""
    n = 0
    targets = [
        DATA_DIR / "slack_cycle.json",
    ]
    # Also remove any collected response files (they hang on the disk
    # otherwise and can confuse the next combine).
    for fname in [
        "slack_collected_vp.json", "slack_collected_mp.json",
    ]:
        targets.append(DATA_DIR / fname)
    # Per-person submitted xlsx files from this cycle (VP_Input_*.xlsx and
    # MP_Input_*.xlsx generated by the engine, plus any collected Slack
    # uploads). Be conservative — only remove files that are clearly
    # cycle-specific (have a person key in the name).
    for pat in ("VP_Input_*.xlsx", "MP_Input_*.xlsx",
                 "vp_response_*.xlsx", "mp_response_*.xlsx"):
        for f in DATA_DIR.glob(pat):
            try:
                f.unlink()
                n += 1
            except OSError:
                pass
    for t in targets:
        if t.exists():
            try:
                t.unlink()
                n += 1
            except OSError:
                pass
    return n


def page_kam_inputs():
    cy, cw = get_current_cw()
    cw_start = cw + 1
    n_fc = FORECAST_WEEKS
    cw_labels = [f"CW{cw_start + j}" for j in range(n_fc)]

    st.title("KAM / CM inputs")
    st.caption("Slack is the main channel. Upload a fresh base once, then run the weekly Slack cycle from here.")

    tab_base, tab_slack, tab_view = st.tabs([
        "📂 Base inputs", "🔔 Slack cycle", "👁️ Combined view"
    ])

    # ---- Load SKU list ----
    plan_path = DATA_DIR / "sku_plan_list.csv"
    if not plan_path.exists():
        st.warning("No sku_plan_list.csv found. Go to SKU list page first.")
        return
    plan = pd.read_csv(plan_path)

    # ---- BASE INPUTS ----
    with tab_base:
        st.subheader("Seed VP / MP base from existing files")
        st.caption(
            "Pick a KAM/CM from the roster and upload their file — filename doesn't matter. "
            "Each slot rewrites that person's rows in `vp_input_detail.csv` / `mp_input_detail.csv`. "
            "Repeat for every person, then click **Save**."
        )

        # Load roster from config
        roster = []
        cfg_path = DATA_DIR / "kam_cm_config.json"
        if cfg_path.exists():
            try:
                cfg = json.loads(cfg_path.read_text())
                for key, info in (cfg.get("kam_cm_config") or {}).items():
                    role = str(info.get("role", "")).upper()
                    if role not in ("VP", "MP"):
                        continue
                    roster.append({
                        "key": key,
                        "name": info.get("display_name", key),
                        "role": role,
                    })
            except Exception as e:
                st.warning(f"Could not read kam_cm_config.json: {e}")

        if not roster:
            st.error("No KAM/CM roster found in data/kam_cm_config.json. "
                     "Configure names and roles there first.")
            return

        from openpyxl import load_workbook as lw
        staged_vp, staged_mp = [], []

        # One uploader slot per roster entry
        for r in roster:
            c1, c2, c3 = st.columns([1.2, 0.5, 3])
            c1.markdown(f"**{r['name']}**")
            c2.markdown(f"_{r['role']}_")
            uf = c3.file_uploader(
                f"File for {r['name']} ({r['role']})",
                type=["xlsx"], key=f"kam_base_up_{r['key']}",
                label_visibility="collapsed",
            )
            if uf is None:
                continue

            tmp_path = DATA_DIR / f"_tmp_{r['key']}.xlsx"
            tmp_path.write_bytes(uf.getvalue())
            target = staged_vp if r["role"] == "VP" else staged_mp
            parsed_any = False

            try:
                wb = lw(tmp_path, data_only=True)
                data_sheets = [s for s in wb.sheetnames if s != "_meta"]
                rows_added = 0
                for sheet_name in data_sheets:
                    ws = wb[sheet_name]
                    buyer = sheet_name if len(data_sheets) > 1 else ""
                    # Auto-detect Type column (VP has Subkategorija → col 6; MP → col 5)
                    type_col = 5
                    for c in range(1, 10):
                        hv = ws.cell(4, c).value
                        if hv and str(hv).strip().lower() == "type":
                            type_col = c
                            break
                    file_cws = {}
                    for c in range(type_col + 1, ws.max_column + 1):
                        v = ws.cell(4, c).value
                        if v and str(v).startswith("CW"):
                            file_cws[str(v)] = c
                    if not file_cws:
                        continue
                    for row in range(5, ws.max_row + 1):
                        sku = ws.cell(row, 1).value
                        if not sku:
                            continue
                        rtype = ws.cell(row, type_col).value or ""
                        row_data = {"sku": str(sku), "type": str(rtype),
                                    "kam": r["name"], "buyer": buyer}
                        has_data = False
                        for cw_label, col in file_cws.items():
                            v = ws.cell(row, col).value
                            if v and isinstance(v, (int, float)) and v > 0:
                                row_data[cw_label] = int(v)
                                has_data = True
                            else:
                                row_data[cw_label] = 0
                        if has_data:
                            target.append(row_data)
                            rows_added += 1
                wb.close()

                if rows_added > 0:
                    parsed_any = True
                    st.caption(f"✅ {r['name']} ({r['role']}): {rows_added} rows (template format)")
            except Exception:
                pass

            if not parsed_any:
                try:
                    vp_rows, mp_rows = _parse_legacy_format(tmp_path, uf.name)
                    # Legacy parser infers channel per-block; keep its routing.
                    for legacy_row in vp_rows:
                        legacy_row["kam"] = r["name"]
                    for legacy_row in mp_rows:
                        legacy_row["kam"] = r["name"]
                    staged_vp.extend(vp_rows)
                    staged_mp.extend(mp_rows)
                    if vp_rows or mp_rows:
                        st.caption(f"✅ {r['name']}: {len(vp_rows)} VP + {len(mp_rows)} MP rows (legacy)")
                    else:
                        st.warning(f"⚠️ {r['name']}: no rows detected.")
                except Exception as e:
                    st.warning(f"⚠️ {r['name']}: {e}")

            tmp_path.unlink(missing_ok=True)

        st.divider()
        if staged_vp:
            st.write(f"**VP staged**: {len(staged_vp)} rows, "
                     f"{len({r['sku'] for r in staged_vp})} SKUs from "
                     f"{len({r['kam'] for r in staged_vp})} KAM(s)")
        if staged_mp:
            st.write(f"**MP staged**: {len(staged_mp)} rows, "
                     f"{len({r['sku'] for r in staged_mp})} SKUs from "
                     f"{len({r['kam'] for r in staged_mp})} CM(s)")

        if (staged_vp or staged_mp) and st.button("💾 Save as base inputs", type="primary",
                                                   key="kam_base_save"):
            for rows, tp in [(staged_vp, "vp"), (staged_mp, "mp")]:
                if not rows:
                    continue
                new_df = pd.DataFrame(rows)
                cw_cols_new = [c for c in new_df.columns if c.startswith("CW")]
                detail_path = DATA_DIR / f"{tp}_input_detail.csv"

                # PER-KAM MERGE: only the KAMs in the upload get their rows
                # replaced. Everyone else's history stays intact. CW columns
                # not in the upload (= past weeks already locked in) are
                # preserved per row.
                kams_in_upload = set(new_df["kam"].astype(str).unique())
                if detail_path.exists():
                    try:
                        old_detail = pd.read_csv(detail_path)
                        # Drop only the rows belonging to KAMs we are uploading.
                        old_kept = old_detail[~old_detail["kam"].astype(str).isin(kams_in_upload)]
                        # Union past-only CW columns (keep them for the new
                        # rows — fill 0 since the new file has no value for them).
                        old_cw_cols = [c for c in old_kept.columns if c.startswith("CW")]
                        for c in old_cw_cols:
                            if c not in new_df.columns:
                                new_df[c] = 0
                        # Make schemas align (preserve past columns that
                        # uploaded sheet doesn't carry).
                        for c in cw_cols_new:
                            if c not in old_kept.columns:
                                old_kept[c] = 0
                        all_cols = (["sku", "type", "kam"]
                                     + (["buyer"] if "buyer" in (set(old_kept.columns) | set(new_df.columns)) else [])
                                     + sorted(set(old_cw_cols) | set(cw_cols_new),
                                               key=lambda x: int(x[2:])))
                        for c in all_cols:
                            if c not in old_kept.columns:
                                old_kept[c] = "" if c in ("buyer",) else 0
                            if c not in new_df.columns:
                                new_df[c] = "" if c in ("buyer",) else 0
                        merged = pd.concat(
                            [old_kept[all_cols], new_df[all_cols]],
                            ignore_index=True,
                        )
                    except Exception as e:
                        st.warning(f"Could not merge with existing {detail_path.name}: {e}. "
                                   "Falling back to full overwrite of THIS upload only.")
                        merged = new_df
                else:
                    merged = new_df
                merged.to_csv(detail_path, index=False)

                # Rebuild summed CSV from the (now merged) detail file using
                # the dedupe rule: max per (sku, type) across buyers, then
                # sum types per sku. Past CW columns are kept by
                # _merge_summed_csv via union with the existing CSV.
                _rebuild_inputs_from_detail()

            st.success(
                f"Saved. Updated only the uploaded KAM/CM(s); other people's "
                "history kept intact. Past CW columns preserved automatically."
            )
            clear_all_caches()

    # ═════════════════════════════════════════════════════════════════
    # SLACK CYCLE — distribute templates & collect responses via Slack
    # ═════════════════════════════════════════════════════════════════
    with tab_slack:
        st.subheader("Slack distribution & collection")
        st.caption("Automate template distribution and response pickup via Slack.")

        # ---- Import agent (handle missing deps gracefully) ----
        agent = None
        try:
            from slack_agent import SlackAgent
            agent = SlackAgent(data_dir=str(DATA_DIR))
        except ImportError as e:
            st.error(f"Cannot import slack_agent: {e}")
            st.caption("Ensure `slack_agent.py` sits next to `app.py` and run `pip install slack_sdk`.")
        except Exception as e:
            st.error(f"Agent init failed: {e}")

        # ---- Config sanity check ----
        cfg_path = DATA_DIR / "kam_cm_config.json"
        slack_cfg, has_token, has_channel = {}, False, False
        if cfg_path.exists():
            with open(cfg_path, encoding="utf-8") as f:
                _cfg = json.load(f)
            slack_cfg = _cfg.get("slack_config", {})
            has_token = bool(slack_cfg.get("bot_token") or os.environ.get("POLLEO_SLACK_TOKEN"))
            has_channel = bool(slack_cfg.get("channel_id"))

        c1, c2, c3 = st.columns(3)
        c1.metric("Slack token", "✅ set" if has_token else "❌ missing")
        c2.metric("Channel", slack_cfg.get("channel_id", "—") if has_channel else "—")
        c3.metric("Deadline", f"{slack_cfg.get('deadline_day','Mon')} {slack_cfg.get('deadline_hour',17)}:00")

        if agent and has_token and has_channel:
            # ---- Current cycle status ----
            st.divider()
            cycle_path = DATA_DIR / "slack_cycle.json"
            cycle = None
            if cycle_path.exists():
                with open(cycle_path, encoding="utf-8") as f:
                    cycle = json.load(f)

                started = (cycle.get("started_at") or "")[:16].replace("T", " ")
                st.markdown(f"**Current cycle:** `{cycle.get('cycle_id','—')}`  ·  started {started}")

                subs = cycle.get("submissions", {})
                n_pending = sum(1 for s in subs.values() if s["status"] == "pending")
                n_ok      = sum(1 for s in subs.values() if s["status"] in ("validated", "submitted"))
                n_err     = sum(1 for s in subs.values() if s["status"] == "error")

                k1, k2, k3, k4 = st.columns(4)
                k1.metric("Total", len(subs))
                k2.metric("Submitted", n_ok)
                k3.metric("Pending", n_pending)
                k4.metric("Errors", n_err)

                icon_map = {"pending": "⏳", "validated": "✅", "submitted": "✅", "error": "⚠️"}
                rows = [{
                    "": icon_map.get(s["status"], "❓"),
                    "Person": s["display_name"],
                    "Role": s["role"],
                    "Status": s["status"],
                    "Submitted": (s.get("submitted_at") or "—")[:16].replace("T", " "),
                    "SKUs": s.get("sku_count", 0),
                    "Units": f"{s.get('total_units', 0):,}",
                } for s in subs.values()]
                st.dataframe(rows, use_container_width=True, hide_index=True)
            else:
                st.info("No active cycle. Click **Distribute templates** below to start one.")

            # ---- Action buttons ----
            st.divider()
            st.markdown("**Actions**")

            col_a, col_b = st.columns(2)

            # ── LEFT: Distribute + Nudge ──
            with col_a:
                any_pending = cycle and any(
                    s["status"] == "pending" for s in cycle.get("submissions", {}).values()
                )
                confirm = True
                if any_pending:
                    confirm = st.checkbox(
                        "Cycle active — start a new one anyway (overwrites current)",
                        key="slack_confirm_redo"
                    )

                if st.button("📤 Distribute templates", type="primary",
                             use_container_width=True, disabled=not confirm,
                             key="slack_btn_distribute"):
                    with st.spinner("Generating templates and posting to Slack…"):
                        r = agent.distribute_all(dry_run=False)
                    if r.get("errors"):
                        st.error(f"{len(r['errors'])} error(s): {'; '.join(r['errors'][:3])}")
                    if r.get("posted"):
                        st.success(f"Posted: {', '.join(r['posted'])}")
                    st.rerun()

                if st.button("🔔 Send nudges to pending",
                             use_container_width=True, key="slack_btn_nudge"):
                    if not cycle:
                        st.warning("No active cycle.")
                    else:
                        with st.spinner("Sending reminders…"):
                            nudged = agent.send_nudges(dry_run=False)
                        if nudged:
                            st.success(f"Nudged: {', '.join(nudged)}")
                        else:
                            st.info("Nothing to nudge — everyone's already in.")

                # ---- Pre-fill diagnostic ----
                with st.expander("🔎 Diagnose pre-fill (why are templates empty?)"):
                    st.caption(
                        "Templates pre-fill from `vp_input_detail.csv` / "
                        "`mp_input_detail.csv` filtered by KAM/CM "
                        "`display_name`. If those files are missing or "
                        "have no rows for a given person, that person's "
                        "template will be blank."
                    )
                    diag_rows = []
                    cfg_path = DATA_DIR / "kam_cm_config.json"
                    if cfg_path.exists():
                        _cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
                        people = _cfg.get("kam_cm_config", {})
                        for key, info in people.items():
                            display = info.get("display_name", key)
                            role = info.get("role", "VP").upper()
                            buyers = info.get("buyers") or []
                            detail_p = DATA_DIR / f"{role.lower()}_input_detail.csv"
                            if detail_p.exists():
                                d = pd.read_csv(detail_p)
                                d.columns = [c.strip().lower() for c in d.columns]
                                if "kam" in d.columns:
                                    matched = d[d["kam"].astype(str).str.lower().str.strip()
                                                   == display.lower().strip()]
                                else:
                                    matched = d.iloc[0:0]
                                buyer_breakdown = ""
                                if "buyer" in matched.columns and len(matched):
                                    bk = matched["buyer"].fillna("(none)").value_counts().to_dict()
                                    buyer_breakdown = ", ".join(
                                        [f"{b}:{n}" for b, n in bk.items()])
                                diag_rows.append({
                                    "Person": display,
                                    "Role": role,
                                    "Detail file": detail_p.name,
                                    "File exists": "✅",
                                    "Total rows in file": len(d),
                                    "Rows for this person": len(matched),
                                    "Configured buyers": ", ".join(buyers) if buyers else "(none)",
                                    "Buyer breakdown in detail": buyer_breakdown or "—",
                                    "Pre-fill outcome": (
                                        "✅ will pre-fill" if len(matched) > 0
                                        else "⚠️ template will be BLANK"
                                    ),
                                })
                            else:
                                diag_rows.append({
                                    "Person": display,
                                    "Role": role,
                                    "Detail file": detail_p.name,
                                    "File exists": "❌ missing",
                                    "Total rows in file": 0,
                                    "Rows for this person": 0,
                                    "Configured buyers": ", ".join(buyers) if buyers else "(none)",
                                    "Buyer breakdown in detail": "—",
                                    "Pre-fill outcome": (
                                        "⚠️ template will be BLANK — "
                                        "upload data via Base inputs tab"
                                    ),
                                })
                    if diag_rows:
                        st.dataframe(pd.DataFrame(diag_rows),
                                       use_container_width=True, hide_index=True)
                        st.caption(
                            "**To seed pre-fill data**: go to Base inputs "
                            "tab → upload the person's existing Excel → "
                            "click *Save as base inputs*. After that, "
                            "Distribute templates will pre-fill correctly. "
                            "Multi-sheet xlsx files automatically tag rows "
                            "with the buyer = sheet name."
                        )
                    else:
                        st.warning("No KAM/CM config found in kam_cm_config.json.")

                # ---- Surgical undo: delete only the last bot message ----
                if st.button("↩️ Delete last bot message",
                             use_container_width=True,
                             key="slack_btn_del_last",
                             help="Surgically delete only the most recent "
                                  "message this bot posted (a wrong template, "
                                  "a typo nudge, etc). Doesn't touch anything "
                                  "older. Use this when one thing went wrong "
                                  "and you don't want to cancel the whole cycle."):
                    if not agent:
                        st.warning("Slack agent not available.")
                    else:
                        with st.spinner("Looking up last bot message…"):
                            r = agent.delete_last_bot_message()
                        if r.get("deleted"):
                            preview = r.get("preview", "")[:80]
                            st.success(
                                f"Deleted last bot message"
                                + (f" — preview: *{preview}…*" if preview else "")
                            )
                        else:
                            errs = r.get("errors", [])
                            if errs:
                                st.error("Could not delete: " + "; ".join(errs[:2]))
                            else:
                                st.info("No recent bot message found.")

                # ---- Cancel / restart cycle ----
                if cycle:
                    with st.expander("🗑️ Cancel current cycle"):
                        st.caption(
                            "Wipes the local cycle state AND deletes the "
                            "bot's messages (templates, headers, status, "
                            "nudges) from the Slack channel. The next "
                            "**Distribute templates** click starts a fresh "
                            "cycle. KAMs / CMs see the channel cleaned up "
                            "and will only receive the new templates."
                        )
                        also_delete_slack = st.checkbox(
                            "Also delete bot messages from Slack channel "
                            "(recommended)",
                            value=True, key="slack_also_delete",
                        )
                        confirm_del = st.checkbox(
                            "I understand — wipe the active cycle.",
                            key="slack_confirm_cancel",
                        )
                        if st.button("🗑️ Cancel cycle now", type="secondary",
                                       use_container_width=True,
                                       disabled=not confirm_del,
                                       key="slack_btn_cancel"):
                            slack_summary = ""
                            if also_delete_slack and agent:
                                with st.spinner("Deleting bot messages from Slack…"):
                                    r = agent.cleanup_channel(max_messages=300)
                                slack_summary = (
                                    f" · Slack: {r.get('deleted', 0)} messages "
                                    f"+ {r.get('files_deleted', 0)} files deleted"
                                )
                                if r.get("errors"):
                                    st.warning(
                                        f"Slack cleanup had {len(r['errors'])} "
                                        f"issue(s). First: {r['errors'][0]}"
                                    )
                            n = _wipe_slack_cycle()
                            st.success(
                                f"Cycle wiped — removed {n} local file(s)"
                                f"{slack_summary}. "
                                "Click 'Distribute templates' to start fresh."
                            )
                            st.rerun()

            # ── RIGHT: Collect + Combine (one button) ──
            with col_b:
                if st.button("📥 Collect + combine responses", type="primary",
                             use_container_width=True, key="slack_btn_collect_combine"):
                    if not cycle:
                        st.warning("No active cycle — nothing to collect.")
                    else:
                        # Step 1 — pull files from Slack thread
                        with st.spinner("Scanning Slack thread and downloading files…"):
                            r_col = agent.collect_responses(dry_run=False)

                        if r_col.get("error"):
                            st.error(f"Collect failed: {r_col['error']}")
                        else:
                            n_collected = len(r_col.get("collected", []))
                            if n_collected:
                                st.success(f"Collected {n_collected} file(s) from Slack")
                            else:
                                st.info("No new files found in the Slack thread.")
                            if r_col.get("errors"):
                                st.warning(f"Collect issues: {'; '.join(r_col['errors'][:3])}")

                            # Step 2 — combine into vp/mp_input.csv
                            with st.spinner("Combining into vp/mp_input.csv…"):
                                r_com = agent.combine_collected()
                            if r_com:
                                for role, stats in r_com.items():
                                    st.success(
                                        f"{role}: {stats['skus']} SKUs from "
                                        f"{stats['kams']} source(s), "
                                        f"{stats['total_units']:,} units → "
                                        f"{role.lower()}_input.csv"
                                    )
                            else:
                                st.info("Nothing to combine yet (no validated files).")

                        st.rerun()

        st.divider()
        st.caption(
            "💡 **Weekly rhythm** — Friday: *Distribute* · Monday 09:00: *Nudge* · "
            "Monday 17:00: *Collect + combine* → Run forecast"
        )

    # ---- VIEW COMBINED ----
    with tab_view:
        st.subheader("Current combined inputs")

        for tp, label in [("vp", "VP (wholesale)"), ("mp", "MP (marketing/retail)")]:
            csv_path = DATA_DIR / f"{tp}_input.csv"
            detail_path = DATA_DIR / f"{tp}_input_detail.csv"

            if csv_path.exists():
                df = pd.read_csv(csv_path)
                cw_cols = [c for c in df.columns if c.startswith("CW")]
                total = int(df[cw_cols].sum().sum())
                st.write(f"**{label}**: {len(df)} SKUs, {total:,} total units")

                if detail_path.exists():
                    detail = pd.read_csv(detail_path)
                    kams = detail["kam"].unique().tolist()
                    st.caption(f"From: {', '.join(kams)}")

                with st.expander(f"View {label} data"):
                    st.dataframe(df, use_container_width=True, hide_index=True)
            else:
                st.caption(f"⚪ {label}: no combined file yet")

        st.divider()
        st.subheader("🧹 Rebuild combined CSVs (max-wins dedupe)")
        st.caption("If the same SKU was entered on multiple buyer sheets "
                   "(e.g. Konzum **and** Spar), the original combine summed "
                   "those rows — inflating the commitment. This rebuild reads "
                   "the per-buyer detail files and re-combines with **MAX per "
                   "CW across duplicates**, then sums on-top + regular per SKU.")
        if st.button("🧹 Rebuild vp_input.csv & mp_input.csv from details",
                      key="kam_rebuild_dedupe"):
            stats = _rebuild_inputs_from_detail()
            for tp, n in stats.items():
                st.success(f"{tp.upper()}: rebuilt {n} SKUs (max-wins).")
            clear_all_caches()
            st.rerun()


def _rebuild_inputs_from_detail() -> dict:
    """Rebuild vp_input.csv / mp_input.csv from their *_input_detail.csv.
    Applies the dedupe rule: MAX per (sku, type) across buyers, then SUM
    types per SKU. Returns {'vp': n_skus, 'mp': n_skus}."""
    out = {}
    for tp in ("vp", "mp"):
        detail_path = DATA_DIR / f"{tp}_input_detail.csv"
        out_path = DATA_DIR / f"{tp}_input.csv"
        if not detail_path.exists():
            out[tp] = 0
            continue
        try:
            detail = pd.read_csv(detail_path)
        except Exception:
            out[tp] = 0
            continue
        cw_cols = [c for c in detail.columns if str(c).startswith("CW")]
        if not cw_cols:
            out[tp] = 0
            continue
        if "type" in detail.columns:
            deduped = detail.groupby(["sku", "type"], as_index=False)[cw_cols].max()
            summed = deduped.groupby("sku", as_index=False)[cw_cols].sum()
        else:
            summed = detail.groupby("sku", as_index=False)[cw_cols].max()
        # Preserve any past CWs already in the existing combined CSV.
        if out_path.exists():
            try:
                prev = pd.read_csv(out_path)
                prev_cw_cols = [c for c in prev.columns if str(c).startswith("CW")]
                preserve_cols = [c for c in prev_cw_cols if c not in cw_cols]
                if preserve_cols:
                    all_skus = sorted(set(prev["sku"]).union(set(summed["sku"])))
                    prev_idx = prev.set_index("sku")
                    new_idx = summed.set_index("sku")
                    rows = []
                    for s in all_skus:
                        row = {"sku": s}
                        for c in preserve_cols:
                            row[c] = prev_idx.at[s, c] if s in prev_idx.index else 0
                        for c in cw_cols:
                            row[c] = new_idx.at[s, c] if s in new_idx.index else 0
                        rows.append(row)
                    summed = pd.DataFrame(rows)
                    cw_sorted = sorted(preserve_cols + cw_cols, key=lambda c: int(c[2:]))
                    summed = summed[["sku"] + cw_sorted]
            except Exception:
                pass
        summed.to_csv(out_path, index=False)
        out[tp] = len(summed)
    return out


def _parse_legacy_format(filepath, source_name="Unknown"):
    """Parse the old 5-row-block VP/MP format. Returns (vp_rows, mp_rows)."""
    from openpyxl import load_workbook as lw
    wb = lw(filepath, data_only=True)
    vp_rows = []
    mp_rows = []

    for sheet_name in wb.sheetnames:
        if "VP" in sheet_name:
            target = vp_rows
        elif "MP" in sheet_name:
            target = mp_rows
        else:
            continue

        ws = wb[sheet_name]

        cw_map = {}
        for c in range(1, ws.max_column + 1):
            v = ws.cell(4, c).value
            if not v:
                continue
            v = str(v)
            if v.startswith("CW") and "/" not in v:
                cw_map[c] = v
            elif "/" in v and "CW" in v:
                cw_label = v.split("/")[-1].strip()
                if cw_label.startswith("CW"):
                    cw_map[c] = cw_label

        current_sku = None
        current_name = ""
        current_cat = ""
        current_ozn = ""

        for r in range(5, ws.max_row + 1):
            col1 = ws.cell(r, 1).value
            if col1 is None:
                continue
            col1 = str(col1).strip()

            if col1 in ("on-top demand", "regular increase") and current_sku:
                row_data = {"sku": current_sku, "name": current_name,
                    "cat": current_cat, "oznaka": current_ozn,
                    "type": col1, "kam": source_name}
                has_data = False
                for col_idx, cw_label in cw_map.items():
                    v = ws.cell(r, col_idx).value
                    if v is not None and isinstance(v, (int, float)) and v > 0:
                        row_data[cw_label] = int(v)
                        has_data = True
                    else:
                        row_data[cw_label] = 0
                if has_data:
                    target.append(row_data)

            elif col1 not in ("", "Filter Match", "MATCH") and \
                 any(c.isdigit() for c in col1) and not col1.startswith("CW"):
                current_sku = col1
                current_name = str(ws.cell(r, 2).value or "")
                current_cat = str(ws.cell(r, 3).value or "")
                current_ozn = str(ws.cell(r, 4).value or "")

    wb.close()
    return vp_rows, mp_rows


def _apply_uploaded_demand_plan(xlsx_bytes: bytes, override: bool = True) -> dict:
    """Take raw bytes of a Polleo_Demand_Plan.xlsx, install it as the live
    plan, and rebuild vp_input.csv / mp_input.csv from its Demand Input VP /
    Demand Input MP sheets.

    override=True  → vp_input.csv / mp_input.csv FULLY REPLACED with what's
                      in the xlsx. Past CW columns and any SKU not present
                      in the xlsx are dropped. Old CSVs are backed up first.
    override=False → past CW columns are kept; SKUs absent from the xlsx
                      keep their previous values; only overlapping CW columns
                      are overwritten.

    Returns: {'vp_skus', 'mp_skus', 'cw_count', 'backup', 'error'}
    """
    from openpyxl import load_workbook as _lw
    import io

    out = {"vp_skus": 0, "mp_skus": 0, "cw_count": 0, "backup": "", "error": ""}

    try:
        wb = _lw(io.BytesIO(xlsx_bytes), data_only=True)
    except Exception as e:
        out["error"] = f"Could not read xlsx: {e}"
        return out

    needed = {"Demand Input VP", "Demand Input MP"}
    missing = [s for s in needed if s not in wb.sheetnames]
    if missing:
        out["error"] = f"xlsx missing required sheets: {', '.join(missing)}"
        return out

    def _read_input_sheet(ws):
        """Read header CW columns and per-SKU summed (on-top + regular) values."""
        cw_to_col = {}
        for c in range(1, ws.max_column + 1):
            v = ws.cell(3, c).value
            if v and str(v).startswith("CW"):
                cw_to_col[str(v)] = c
        if not cw_to_col:
            for c in range(1, ws.max_column + 1):
                v = ws.cell(4, c).value
                if v and str(v).startswith("CW"):
                    cw_to_col[str(v)] = c
        per_sku = {}
        for r in range(4, ws.max_row + 1):
            sku = ws.cell(r, 1).value
            if not sku:
                continue
            row_vals = {}
            has_data = False
            for cw, col in cw_to_col.items():
                v = ws.cell(r, col).value
                try:
                    fv = float(v) if v not in (None, "") else 0.0
                except (TypeError, ValueError):
                    fv = 0.0
                row_vals[cw] = fv
                if fv > 0:
                    has_data = True
            if not has_data:
                continue
            cur = per_sku.setdefault(sku, {c: 0.0 for c in cw_to_col})
            for cw, fv in row_vals.items():
                cur[cw] = cur.get(cw, 0.0) + fv
        return per_sku, list(cw_to_col.keys())

    vp_per_sku, vp_cws = _read_input_sheet(wb["Demand Input VP"])
    mp_per_sku, mp_cws = _read_input_sheet(wb["Demand Input MP"])
    wb.close()

    out["vp_skus"] = len(vp_per_sku)
    out["mp_skus"] = len(mp_per_sku)
    out["cw_count"] = len(set(vp_cws + mp_cws))

    # Backup current xlsx
    hist = DATA_DIR / "plan_history"
    hist.mkdir(exist_ok=True)
    if OUTPUT_FILE.exists():
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_path = hist / f"Polleo_Demand_Plan_{ts}.xlsx"
        try:
            backup_path.write_bytes(OUTPUT_FILE.read_bytes())
            out["backup"] = backup_path.name
        except Exception as e:
            out["error"] = f"Could not back up old xlsx: {e}"
            return out

    # Install new xlsx
    try:
        OUTPUT_FILE.write_bytes(xlsx_bytes)
    except Exception as e:
        out["error"] = f"Could not save new xlsx: {e}"
        return out

    def _override_csv(per_sku: dict, cws_in_xlsx: list, fname: str):
        """Full replace: backup current CSV, then write only what's in xlsx."""
        target = DATA_DIR / fname
        if target.exists():
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            try:
                target.with_name(f"{target.stem}.{ts}.bak.csv").write_bytes(
                    target.read_bytes())
            except Exception:
                pass
        cw_sorted = sorted(cws_in_xlsx, key=lambda c: int(c[2:]))
        rows = []
        for s, vals in sorted(per_sku.items()):
            row = {"sku": s}
            for c in cw_sorted:
                row[c] = vals.get(c, 0)
            rows.append(row)
        df = pd.DataFrame(rows, columns=["sku"] + cw_sorted)
        df.to_csv(target, index=False)

    def _merge_csv(per_sku: dict, cws_in_xlsx: list, fname: str):
        target = DATA_DIR / fname
        new_cols = list(cws_in_xlsx)
        if target.exists():
            try:
                prev = pd.read_csv(target)
                prev_cw_cols = [c for c in prev.columns if c.startswith("CW")]
                preserve_cols = [c for c in prev_cw_cols if c not in new_cols]
                all_skus = sorted(set(prev["sku"]).union(set(per_sku.keys())))
                prev_idx = prev.set_index("sku")
                rows = []
                for s in all_skus:
                    row = {"sku": s}
                    for c in preserve_cols:
                        row[c] = prev_idx.at[s, c] if s in prev_idx.index else 0
                    fresh = per_sku.get(s, {})
                    for c in new_cols:
                        row[c] = fresh.get(c, 0)
                    rows.append(row)
                df = pd.DataFrame(rows)
                cw_sorted = sorted(preserve_cols + new_cols, key=lambda c: int(c[2:]))
                df = df[["sku"] + cw_sorted]
            except Exception:
                df = pd.DataFrame([{"sku": s, **vals} for s, vals in per_sku.items()])
        else:
            df = pd.DataFrame([{"sku": s, **vals} for s, vals in per_sku.items()])
        df.to_csv(target, index=False)

    apply_csv = _override_csv if override else _merge_csv
    if vp_per_sku:
        apply_csv(vp_per_sku, vp_cws, "vp_input.csv")
    if mp_per_sku:
        apply_csv(mp_per_sku, mp_cws, "mp_input.csv")

    return out


def page_download():
    st.title("Download forecast")

    if OUTPUT_FILE.exists():
        mod = datetime.fromtimestamp(OUTPUT_FILE.stat().st_mtime)
        st.success(f"Latest forecast — {mod.strftime('%Y-%m-%d %H:%M')}")
        with open(OUTPUT_FILE, "rb") as f:
            st.download_button(
                "📥 Download Polleo_Demand_Plan.xlsx",
                data=f.read(),
                file_name="Polleo_Demand_Plan.xlsx",
                mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                type="primary", use_container_width=True
            )
        with st.expander("Download data files"):
            for fname in REQUIRED_CSV:
                p = DATA_DIR / fname
                if p.exists():
                    st.download_button(fname, p.read_bytes(), fname, "text/csv", key=f"dl_{fname}")

        st.divider()
        st.caption("**Supply module bridge**")
        fsp = DATA_DIR / "forecast_for_supply.csv"
        colA, colB = st.columns([1, 1])
        if colA.button("🔄 Refresh forecast_for_supply.csv", use_container_width=True):
            src, err = write_forecast_for_supply()
            ts = datetime.now().strftime("%Y-%m-%d %H:%M")
            if err:
                st.error(f"GREŠKA: forecast_for_supply.csv nije zapisan. "
                         f"Supply modul koristi stare podatke! ({err})")
            elif src == "output_total":
                st.success(f"✅ Refreshed at {ts} — from **Demand Output - Total** sheet (authoritative).")
            elif src == "reconstructed":
                st.success(f"✅ Refreshed at {ts} — reconstructed from Demand Planning blocks. "
                           "Tip: open `Polleo_Demand_Plan.xlsx` in Excel and save it "
                           "to cache the Demand Output - Total values for direct reads.")
            else:
                st.warning("No demand plan loaded yet.")
        if fsp.exists():
            mod_fsp = datetime.fromtimestamp(fsp.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
            colB.caption(f"✅ Last refreshed: {mod_fsp}")
            colB.download_button("forecast_for_supply.csv", fsp.read_bytes(),
                                 "forecast_for_supply.csv", "text/csv",
                                 key="dl_fsp", use_container_width=True)
        else:
            colB.caption("⚪ Not yet generated")
    else:
        st.warning("No forecast generated yet.")

    # ---- Upload an externally-generated Demand Plan xlsx ----
    st.divider()
    st.subheader("⬆️ Upload Demand Plan xlsx")
    st.caption(
        "Drop a `Polleo_Demand_Plan.xlsx` produced elsewhere. The current file "
        "is backed up to `data/plan_history/`, the new one becomes the live "
        "plan, and **VP / MP on-top values are extracted from its `Demand Input "
        "VP` and `Demand Input MP` sheets and merged into `vp_input.csv` / "
        "`mp_input.csv`** (past CW columns are preserved). Use this when "
        "another planner ran the forecast on their machine and you want to "
        "load their plan + commitments without re-running the engine."
    )

    up_plan = st.file_uploader(
        "Pick a Polleo_Demand_Plan.xlsx",
        type=["xlsx"], key="upload_demand_plan",
    )
    if up_plan is not None:
        mode = st.radio(
            "VP / MP CSV update mode",
            ["Override (xlsx fully replaces existing inputs)",
             "Merge (preserve past CW history)"],
            index=0, key="upload_plan_mode",
            help="Override = vp_input.csv and mp_input.csv become exactly what's in "
                 "the xlsx — past CW columns and SKUs not in the xlsx are dropped. "
                 "Merge = keep past CW columns and any SKU not present in the xlsx, "
                 "overwrite only the CWs that are in the xlsx.",
        )
        is_override = mode.startswith("Override")
        if st.button("Apply uploaded plan", type="primary", key="btn_apply_plan"):
            stats = _apply_uploaded_demand_plan(
                up_plan.getvalue(),
                override=is_override,
            )
            if stats.get("error"):
                st.error(stats["error"])
            else:
                action = "Replaced" if is_override else "Merged"
                st.success(
                    f"Plan installed. {action} VP: {stats['vp_skus']} SKUs, "
                    f"MP: {stats['mp_skus']} SKUs across "
                    f"{stats['cw_count']} CW column(s). "
                    f"Old plan archived as {stats['backup']}."
                )
                clear_all_caches()
                st.rerun()


# ==================================================================
# NEW PAGES: Accuracy, Exceptions, What-If, FVA, Bridge, Consensus, S&OP
# ==================================================================

# ==================================================================
# FA HELPERS — shared by the three FA tabs
# ==================================================================

def _collect_vp_history():
    """Walk all consensus snapshots and return a dict:
        (sku, year, week) -> vp_on_top_value

    Uses 'latest snapshot wins' semantics: for each (SKU, target week), the
    value from the most recently saved snapshot whose forecast horizon
    contained that week is kept — this represents the final VP commitment
    before that week actually happened.
    Only on-top demand rows with value > 0 are included.
    Snapshots saved before v3.6 (no vp_inputs field) are skipped.
    """
    snaps = load_consensus_snapshots()  # sorted newest-first by filename
    # Iterate oldest-first so newer values overwrite older ones
    snaps_oldest_first = list(reversed(snaps))
    vp_hist = {}
    for snap in snaps_oldest_first:
        vp_inputs = snap.get("vp_inputs") or {}
        if not vp_inputs:
            continue
        snap_cw = int(snap.get("cw", 0) or 0)
        ts = snap.get("timestamp", "")
        try:
            snap_year = int(snap.get("snap_year") or (ts[:4] if ts else datetime.now().year))
        except (TypeError, ValueError):
            snap_year = datetime.now().year
        for sku, per_cw in vp_inputs.items():
            if not isinstance(per_cw, dict):
                continue
            for cw_label, vp_val in per_cw.items():
                try:
                    target_cw = int(str(cw_label).replace("CW", ""))
                    v = float(vp_val)
                except (TypeError, ValueError):
                    continue
                if v <= 0:
                    continue
                # Year inference: if target cw < snap cw, we crossed year boundary
                target_year = snap_year if target_cw >= snap_cw else snap_year + 1
                vp_hist[(sku, target_year, target_cw)] = v
    return vp_hist


def _collect_mp_history():
    """Same shape as _collect_vp_history but for MP on-top (retail)."""
    snaps = load_consensus_snapshots()
    snaps_oldest_first = list(reversed(snaps))
    mp_hist = {}
    for snap in snaps_oldest_first:
        mp_inputs = snap.get("mp_inputs") or {}
        if not mp_inputs:
            continue
        snap_cw = int(snap.get("cw", 0) or 0)
        ts = snap.get("timestamp", "")
        try:
            snap_year = int(snap.get("snap_year") or (ts[:4] if ts else datetime.now().year))
        except (TypeError, ValueError):
            snap_year = datetime.now().year
        for sku, per_cw in mp_inputs.items():
            if not isinstance(per_cw, dict):
                continue
            for cw_label, mp_val in per_cw.items():
                try:
                    target_cw = int(str(cw_label).replace("CW", ""))
                    v = float(mp_val)
                except (TypeError, ValueError):
                    continue
                if v <= 0:
                    continue
                target_year = snap_year if target_cw >= snap_cw else snap_year + 1
                mp_hist[(sku, target_year, target_cw)] = v
    return mp_hist


@st.cache_data(ttl=60)
def _load_total_fa_data(_sc):
    """Build the 'total demand' forecast-vs-actual DataFrame from
    backtest_fa.csv and forecast_log.csv (qty_total on the actual side).
    Returns DataFrame with sku, year, week, forecast, actual (+ cat, oznaka)
    or None if no data.

    The underscore in `_sc` tells Streamlit's cache_data to skip hashing the
    DataFrame argument — upstream loaders already cache sc with the same TTL,
    and rebuilding FA data on every widget click was the main page-render lag.
    """
    sc = _sc
    bt_path = DATA_DIR / "backtest_fa.csv"
    log_path = DATA_DIR / "forecast_log.csv"

    fa_data = None
    if bt_path.exists():
        fa_data = pd.read_csv(bt_path)

    if log_path.exists() and sc is not None:
        log_df = pd.read_csv(log_path)
        if len(log_df) > 0:
            # Multiple runs append rows for the same (sku, target_year, target_week)
            # over time. Global FA cares about the LATEST forecast per week, so
            # collapse the log here. Live FA reads the same file but picks the
            # FIRST forecast — see _build_live_fa_data().
            # Sort on run_id (second precision); run_date is minute-precision
            # only and would tie for same-minute runs.
            sort_col = "run_id" if "run_id" in log_df.columns else "run_date"
            if sort_col in log_df.columns:
                log_df = (log_df.sort_values(sort_col)
                                .drop_duplicates(subset=["sku", "target_year", "target_week"],
                                                  keep="last"))
            actuals = sc.groupby(["sku", "year", "week"])["qty_total"].sum().reset_index()
            log_merged = log_df.merge(actuals, left_on=["sku", "target_year", "target_week"],
                                       right_on=["sku", "year", "week"], how="inner")
            if len(log_merged) > 0:
                log_merged = log_merged.rename(columns={"qty_total": "actual"})
                log_merged["year"] = log_merged["target_year"]
                log_merged["week"] = log_merged["target_week"]
                log_merged = log_merged[["sku", "year", "week", "forecast", "actual"]]
                plan_csv = DATA_DIR / "sku_plan_list.csv"
                if plan_csv.exists():
                    plan = pd.read_csv(plan_csv)
                    cols = ["sku", "cat", "oznaka"]
                    if "total_xyz" in plan.columns:
                        cols.append("total_xyz")
                    log_merged = log_merged.merge(plan[cols].drop_duplicates("sku"),
                                                   on="sku", how="left")
                    if "total_xyz" in log_merged.columns:
                        log_merged = log_merged.rename(columns={"total_xyz": "xyz"})
                if fa_data is not None:
                    existing_keys = set(zip(fa_data["sku"], fa_data["year"], fa_data["week"]))
                    lm_keys = list(zip(log_merged["sku"], log_merged["year"], log_merged["week"]))
                    keep_mask = np.array([k not in existing_keys for k in lm_keys])
                    log_new = log_merged[keep_mask]
                    fa_data = pd.concat([fa_data, log_new], ignore_index=True)
                else:
                    fa_data = log_merged

    if fa_data is None or len(fa_data) == 0:
        return None

    # Ensure cat/oznaka metadata
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    if plan_csv.exists():
        plan = pd.read_csv(plan_csv)
        if "oznaka" not in fa_data.columns or fa_data["oznaka"].isna().all():
            fa_data["oznaka"] = fa_data["sku"].map(dict(zip(plan["sku"], plan["oznaka"]))).fillna("")
            fa_data["cat"] = fa_data["sku"].map(dict(zip(plan["sku"], plan["cat"]))).fillna("")
        if "xyz" not in fa_data.columns and "total_xyz" in plan.columns:
            fa_data["xyz"] = fa_data["sku"].map(dict(zip(plan["sku"], plan["total_xyz"]))).fillna("N/A")
    if "xyz" not in fa_data.columns:
        fa_data["xyz"] = "N/A"
    return fa_data


def _build_live_fa_data(_sc):
    """Live FA dataset: for each (sku, year, week) in forecast_log.csv, picks
    the FIRST forecast made for that week (earliest run_date) and compares it
    against the actual from sales_clean.csv. Filters to actual > 0.

    Returns a DataFrame compatible with _render_fa_tab (same columns as the
    other FA datasets: sku, year, week, forecast, actual, cat, oznaka, xyz,
    error, variance, fa, fa_signed, hit, cw_label, month) or None if no data.

    Independent of backtest_fa.csv. Independent of Global FA's "keep latest"
    dedupe — same source file, different perspective.
    """
    sc = _sc
    log_path = DATA_DIR / "forecast_log.csv"
    if not log_path.exists() or sc is None:
        return None
    log_df = pd.read_csv(log_path)
    if len(log_df) == 0 or "run_date" not in log_df.columns:
        return None

    # Pick the FIRST forecast per (sku, target_year, target_week).
    # Sort on run_id (second precision) — run_date is minute-precision only.
    sort_col = "run_id" if "run_id" in log_df.columns else "run_date"
    log_first = (log_df.sort_values(sort_col)
                       .drop_duplicates(subset=["sku", "target_year", "target_week"],
                                         keep="first")
                       .copy())

    # Use forecast_total (full headline number: baseline × factor + VP + MP)
    # rather than baseline only, so Live FA reflects what the planner committed
    # to at run time, not just the model's raw output.
    if "forecast_total" in log_first.columns:
        log_first["forecast"] = log_first["forecast_total"].round().astype(int)
    elif "forecast" not in log_first.columns:
        return None

    actuals = sc.groupby(["sku", "year", "week"])["qty_total"].sum().reset_index()
    merged = log_first.merge(actuals,
                              left_on=["sku", "target_year", "target_week"],
                              right_on=["sku", "year", "week"], how="inner")
    if len(merged) == 0:
        return None

    merged = merged.rename(columns={"qty_total": "actual"})
    merged["year"] = merged["target_year"]
    merged["week"] = merged["target_week"]
    keep = ["sku", "year", "week", "forecast", "actual"]
    if "model_used" in merged.columns:
        merged["model"] = merged["model_used"]
        keep.append("model")
    fa = merged[keep].copy()

    # Attach oznaka / cat / xyz from sku_plan_list.csv (same as Global FA)
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    if plan_csv.exists():
        plan = pd.read_csv(plan_csv)
        fa["oznaka"] = fa["sku"].map(dict(zip(plan["sku"], plan["oznaka"]))).fillna("")
        fa["cat"] = fa["sku"].map(dict(zip(plan["sku"], plan["cat"]))).fillna("")
        if "total_xyz" in plan.columns:
            fa["xyz"] = fa["sku"].map(dict(zip(plan["sku"], plan["total_xyz"]))).fillna("N/A")
        else:
            fa["xyz"] = "N/A"
    else:
        fa["oznaka"] = ""
        fa["cat"] = ""
        fa["xyz"] = "N/A"

    fa = fa[fa["actual"] > 0].copy()
    if len(fa) == 0:
        return None

    # Standard FA metric columns (same recipe as _build_fa_dataset post-processing)
    fa["error"] = np.abs(fa["forecast"] - fa["actual"])
    fa["variance"] = fa["forecast"] - fa["actual"]
    fa["fa"] = np.maximum(0, 1 - fa["error"] / fa["actual"])
    fa["fa_signed"] = fa["forecast"] / fa["actual"]
    fa["hit"] = (fa["error"] / fa["actual"].clip(lower=1) <= 0.3).astype(int)
    fa["cw_label"] = "CW" + fa["week"].astype(str)
    _yw_unique = fa[["year", "week"]].drop_duplicates()
    _yw_unique["month"] = [
        _week_to_month_name(int(y), int(w))
        for y, w in zip(_yw_unique["year"], _yw_unique["week"])
    ]
    fa = fa.merge(_yw_unique, on=["year", "week"], how="left")
    return fa


@st.cache_data(ttl=120)
def _build_per_buyer_vp_fa(_sc):
    """Per-(KAM, buyer) FA dataset.

    Forecast side: vp_input_detail.csv melted on CW columns, summed per
    (sku, kam, buyer, year, week). Uses the CURRENT state of the file —
    no historical snapshot of per-buyer commitments exists yet, so this
    reflects today's committed VP per buyer projected back across the file's
    CW horizon. Going forward, snapshot-time per-buyer capture would
    improve fidelity.

    Actuals side: sales_detailed.csv filtered to wholesale documents
    (tip_dok ∈ {TRC, VPT, VPB, RIZ}) and tagged with (kam, buyer) via
    data/buyer_partner_map.json. '__catchall__' in a buyer's partner list
    means 'every wholesale partner NOT explicitly listed under any other
    buyer of the same KAM'.

    Returns DataFrame keyed (sku, year, week, kam, buyer) with standard
    FA columns (forecast, actual, error, fa, fa_signed, hit, cw_label,
    month, oznaka, cat, xyz) PLUS kam + buyer + partner_count for the
    custom renderer. Returns None if any required input is missing.
    """
    sc = _sc
    detail_path = DATA_DIR / "vp_input_detail.csv"
    sd_path = DATA_DIR / "sales_detailed.csv"
    map_path = DATA_DIR / "buyer_partner_map.json"
    if not (detail_path.exists() and sd_path.exists() and map_path.exists()):
        return None
    try:
        vp = pd.read_csv(detail_path)
    except Exception:
        return None
    if "buyer" not in vp.columns or vp["buyer"].astype(str).str.strip().eq("").all():
        return None

    # ---- FORECAST side: melt VP detail per (sku, kam, buyer, CWxx) ----
    cw_cols = [c for c in vp.columns if str(c).startswith("CW")]
    if not cw_cols:
        return None
    vp["sku"] = vp["sku"].astype(str)
    vp["kam"] = vp["kam"].astype(str)
    vp["buyer"] = vp["buyer"].astype(str)
    vp_melt = vp.melt(id_vars=["sku", "kam", "buyer"], value_vars=cw_cols,
                       var_name="cw_label", value_name="forecast")
    vp_melt["forecast"] = pd.to_numeric(vp_melt["forecast"], errors="coerce").fillna(0)
    vp_melt = vp_melt[vp_melt["forecast"] > 0]
    vp_agg = (vp_melt.groupby(["sku", "kam", "buyer", "cw_label"], as_index=False)
                       .agg(forecast=("forecast", "sum")))
    # Year inference: assume current ISO year. CW labels in the detail
    # file only cover the next ~13 weeks from the latest upload, so a
    # naive same-year assumption is sufficient here.
    cur_y = datetime.now().isocalendar()[0]
    vp_agg["year"] = cur_y
    vp_agg["week"] = vp_agg["cw_label"].str.replace("CW", "", regex=False).astype(int)
    vp_agg = vp_agg[["sku", "year", "week", "kam", "buyer", "forecast"]]

    # ---- ACTUALS side: sales_detailed filtered to wholesale + tagged ----
    try:
        sd = pd.read_csv(sd_path, low_memory=False)
    except Exception:
        return None
    WHOLESALE_TIPS = {"TRC", "VPT", "VPB", "RIZ"}
    sd = sd[sd["tip_dok"].astype(str).isin(WHOLESALE_TIPS)].copy()
    if len(sd) == 0:
        return None
    sd["sku"] = sd["sku"].astype(str)
    sd["year"] = pd.to_numeric(sd["year"], errors="coerce").astype("Int64")
    sd["week"] = pd.to_numeric(sd["week"], errors="coerce").astype("Int64")
    sd = sd[sd["year"].notna() & sd["week"].notna()].copy()
    sd["year"] = sd["year"].astype(int)
    sd["week"] = sd["week"].astype(int)
    sd["partner"] = sd["naziv_partnera"].astype(str).str.strip()
    sd["kolicina"] = pd.to_numeric(sd["kolicina"], errors="coerce").fillna(0)

    try:
        with open(map_path, encoding="utf-8") as _f:
            bm = json.load(_f)
    except Exception:
        return None
    kams_map = (bm or {}).get("kams", {}) or {}
    if not kams_map:
        return None

    # Explicit (partner -> kam, buyer) pairs; track each KAM's explicit
    # partner set so __catchall__ knows what to EXCLUDE.
    explicit_rows = []
    explicit_partners_per_kam = {}
    catchall_buyers = []  # [(kam, buyer)] — usually 0 or 1 per KAM
    for kam, buyers in kams_map.items():
        explicit_partners_per_kam.setdefault(kam, set())
        for buyer, plist in (buyers or {}).items():
            for p in (plist or []):
                if p == "__catchall__":
                    catchall_buyers.append((kam, buyer))
                else:
                    explicit_rows.append({"partner": p, "kam": kam, "buyer": buyer})
                    explicit_partners_per_kam[kam].add(p)

    explicit_df = pd.DataFrame(explicit_rows, columns=["partner", "kam", "buyer"])

    pieces = []
    if not explicit_df.empty:
        matched = sd.merge(explicit_df, on="partner", how="inner")
        pieces.append(matched)
    for (k, b) in catchall_buyers:
        ex_set = explicit_partners_per_kam.get(k, set())
        catch = sd[~sd["partner"].isin(ex_set)].copy()
        catch["kam"] = k
        catch["buyer"] = b
        pieces.append(catch)
    if not pieces:
        return None
    tagged = pd.concat(pieces, ignore_index=True)
    if len(tagged) == 0:
        return None

    actuals = (tagged.groupby(["sku", "year", "week", "kam", "buyer"], as_index=False)
                       .agg(actual=("kolicina", "sum")))

    # ---- OUTER MERGE so under- and over-commitment are both surfaced ----
    fa = vp_agg.merge(actuals, on=["sku", "year", "week", "kam", "buyer"], how="outer")
    fa["forecast"] = fa["forecast"].fillna(0).astype(int)
    fa["actual"] = fa["actual"].fillna(0).astype(int)
    # Keep only rows with at least one side > 0 — pure-zero rows aren't useful
    fa = fa[(fa["forecast"] > 0) | (fa["actual"] > 0)].copy()
    if len(fa) == 0:
        return None

    # Attach plan-list metadata
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    if plan_csv.exists():
        plan = pd.read_csv(plan_csv)
        plan["sku"] = plan["sku"].astype(str)
        fa["oznaka"] = fa["sku"].map(dict(zip(plan["sku"], plan["oznaka"]))).fillna("")
        fa["cat"] = fa["sku"].map(dict(zip(plan["sku"], plan["cat"]))).fillna("")
        if "total_xyz" in plan.columns:
            fa["xyz"] = fa["sku"].map(dict(zip(plan["sku"], plan["total_xyz"]))).fillna("N/A")
        else:
            fa["xyz"] = "N/A"
    else:
        fa["oznaka"] = ""
        fa["cat"] = ""
        fa["xyz"] = "N/A"

    # FA columns — but only valid where actual > 0 (zero-actual rows are
    # "commitment with no sale", we keep them visible but mark FA as NaN
    # so they don't poison averages).
    fa["error"] = np.abs(fa["forecast"] - fa["actual"])
    fa["variance"] = fa["forecast"] - fa["actual"]
    nonzero = fa["actual"] > 0
    fa["fa"] = np.where(nonzero, np.maximum(0, 1 - fa["error"] / fa["actual"].clip(lower=1)), np.nan)
    fa["fa_signed"] = np.where(nonzero, fa["forecast"] / fa["actual"].clip(lower=1), np.nan)
    fa["hit"] = np.where(nonzero, (fa["error"] / fa["actual"].clip(lower=1) <= 0.3).astype(int), 0)
    fa["cw_label"] = "CW" + fa["week"].astype(str)
    _yw = fa[["year", "week"]].drop_duplicates()
    _yw["month"] = [_week_to_month_name(int(y), int(w))
                     for y, w in zip(_yw["year"], _yw["week"])]
    fa = fa.merge(_yw, on=["year", "week"], how="left")
    return fa


@st.cache_data(ttl=60)
def _build_fa_dataset(mode, _sc):
    """Build a forecast-vs-actual dataset for one of three modes.

    mode:
        'total'              — total model forecast vs qty_total (all SKUs).
        'kam_cm_projections' — on-top commitment vs actual sales in that planner's
                               own channel. VP → qty_wholesale (TRC/VPT/VPB/RAC).
                               MP → qty_retail (RCM). 'both' sums VP+MP and actuals
                               from wholesale+retail. Carries an 'input_kind' column.
        'model_only'         — baseline forecast and qty_total with the planner's
                               channel subtracted for SKU-weeks they committed on.
                               VP on-top → wholesale stripped from both sides.
                               MP on-top → retail stripped from both sides.

    Returns DataFrame with columns:
        sku, year, week, forecast, actual, cat, oznaka,
        error, variance, fa, hit, cw_label, month (+ input_kind for kam_cm_projections)
    or None if no overlapping data.
    """
    sc = _sc  # alias: `_sc` param only skips Streamlit cache-key hashing
    if mode == "total":
        fa = _load_total_fa_data(sc)
        if fa is None or len(fa) == 0:
            return None

    elif mode in ("kam_cm_projections", "model_only"):
        if sc is None:
            return None
        vp_hist = _collect_vp_history()
        mp_hist = _collect_mp_history()

        # Channel actuals keyed by (sku, year, week)
        ch = sc.groupby(["sku", "year", "week"]).agg(
            qty_retail=("qty_retail", "sum"),
            qty_wholesale=("qty_wholesale", "sum"),
        ).reset_index()
        ws_key = {(r["sku"], int(r["year"]), int(r["week"])): float(r["qty_wholesale"])
                  for _, r in ch.iterrows()}
        rt_key = {(r["sku"], int(r["year"]), int(r["week"])): float(r["qty_retail"])
                  for _, r in ch.iterrows()}

        if mode == "kam_cm_projections":
            if not vp_hist and not mp_hist:
                return None
            keys = set(vp_hist) | set(mp_hist)
            rows = []
            for (sku, y, w) in keys:
                vp = float(vp_hist.get((sku, y, w), 0.0))
                mp = float(mp_hist.get((sku, y, w), 0.0))
                ws_actual = ws_key.get((sku, y, w), 0.0)
                rt_actual = rt_key.get((sku, y, w), 0.0)
                if vp > 0 and mp > 0:
                    kind, fcst, act = "both", vp + mp, ws_actual + rt_actual
                elif vp > 0:
                    kind, fcst, act = "vp_only", vp, ws_actual
                else:
                    kind, fcst, act = "mp_only", mp, rt_actual
                rows.append({"sku": sku, "year": y, "week": w,
                             "forecast": round(fcst), "actual": round(act),
                             "input_kind": kind})
            if not rows:
                return None
            fa = pd.DataFrame(rows)
            plan_csv = DATA_DIR / "sku_plan_list.csv"
            if plan_csv.exists():
                plan = pd.read_csv(plan_csv)
                fa["oznaka"] = fa["sku"].map(dict(zip(plan["sku"], plan["oznaka"]))).fillna("")
                fa["cat"] = fa["sku"].map(dict(zip(plan["sku"], plan["cat"]))).fillna("")
                if "total_xyz" in plan.columns:
                    fa["xyz"] = fa["sku"].map(dict(zip(plan["sku"], plan["total_xyz"]))).fillna("N/A")
                else:
                    fa["xyz"] = "N/A"
            else:
                fa["oznaka"] = ""
                fa["cat"] = ""
                fa["xyz"] = "N/A"

        else:  # model_only
            fa_total = _load_total_fa_data(sc)
            if fa_total is None or len(fa_total) == 0:
                return None
            fa = fa_total.copy()
            fa["_vp"] = fa.apply(
                lambda r: vp_hist.get((r["sku"], int(r["year"]), int(r["week"])), 0.0), axis=1)
            fa["_mp"] = fa.apply(
                lambda r: mp_hist.get((r["sku"], int(r["year"]), int(r["week"])), 0.0), axis=1)
            fa["_ws_actual"] = fa.apply(
                lambda r: ws_key.get((r["sku"], int(r["year"]), int(r["week"])), 0.0), axis=1)
            fa["_rt_actual"] = fa.apply(
                lambda r: rt_key.get((r["sku"], int(r["year"]), int(r["week"])), 0.0), axis=1)
            # Strip the planner-owned channel from both forecast and actual when
            # an on-top exists. Retail is stripped as (vp + rt_actual) capped so
            # we don't go negative on thin-volume weeks.
            fa["forecast"] = (
                fa["forecast"]
                - fa["_vp"].where(fa["_vp"] > 0, 0)
                - fa["_mp"].where(fa["_mp"] > 0, 0)
            ).clip(lower=0).round().astype(int)
            fa["actual"] = (
                fa["actual"]
                - fa["_ws_actual"].where(fa["_vp"] > 0, 0)
                - fa["_rt_actual"].where(fa["_mp"] > 0, 0)
            ).clip(lower=0).round().astype(int)
            fa = fa.drop(columns=["_vp", "_mp", "_ws_actual", "_rt_actual"])

    else:
        return None

    # --- Common post-processing ---
    fa = fa[fa["actual"] > 0].copy()
    if len(fa) == 0:
        return None

    fa["error"] = np.abs(fa["forecast"] - fa["actual"])
    fa["variance"] = fa["forecast"] - fa["actual"]
    fa["fa"] = np.maximum(0, 1 - fa["error"] / fa["actual"])
    # Signed FA — ratio forecast / actual. >100% = over-forecast, <100% = under-forecast.
    fa["fa_signed"] = fa["forecast"] / fa["actual"]
    fa["hit"] = (fa["error"] / fa["actual"].clip(lower=1) <= 0.3).astype(int)
    fa["cw_label"] = "CW" + fa["week"].astype(str)
    # Vectorised month lookup — build once over the unique (year, week) pairs
    # and map back, avoiding a row-wise .apply (was the main slowdown on every
    # FA widget interaction).
    _yw_unique = fa[["year", "week"]].drop_duplicates()
    _yw_unique["month"] = [
        _week_to_month_name(int(y), int(w))
        for y, w in zip(_yw_unique["year"], _yw_unique["week"])
    ]
    fa = fa.merge(_yw_unique, on=["year", "week"], how="left")
    return fa


def _fa_xlsx_download(df, label, filename, key):
    """Render a Streamlit download button that emits `df` as a single-sheet xlsx."""
    if df is None or len(df) == 0:
        return
    from io import BytesIO
    buf = BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as xw:
        df.to_excel(xw, index=False, sheet_name="FA")
    st.download_button(
        label, buf.getvalue(), filename,
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        key=key,
    )


def _render_per_buyer_vp_fa(fa):
    """Renders the 'VP per buyer' sub-tab inside KAM/CM projections FA.

    Splits the dataset by (kam, buyer). Forecast = current VP commitment for
    that buyer; Actual = wholesale qty sold to that buyer's mapped ERP
    partner(s). Shows a KAM filter, summary tiles, per-buyer breakdown
    table, and a SKU-week drilldown for the selected buyer."""
    if fa is None or len(fa) == 0:
        st.info(
            "Per-buyer FA needs three inputs: `vp_input_detail.csv` (with a "
            "populated `buyer` column), `sales_detailed.csv`, and "
            "`data/buyer_partner_map.json`. Re-run with a multi-sheet KAM "
            "upload + a weekly sales update."
        )
        return

    st.caption(
        "**Per-buyer VP accuracy.** Forecast = current VP commitment "
        "from `vp_input_detail.csv` for that buyer. Actual = wholesale qty "
        "(tip_dok ∈ TRC/VPT/VPB/RIZ) sold to that buyer's mapped ERP "
        "partner names — mapping lives in `data/buyer_partner_map.json`. "
        "Rows with **0 forecast and > 0 actual** mean the KAM under-committed "
        "for that buyer; rows with **forecast > 0 and 0 actual** mean the "
        "buyer didn't order what was promised yet."
    )

    # ---- Filters ----
    c1, c2, c3 = st.columns([1.3, 2, 1.5])
    kams = sorted(fa["kam"].dropna().astype(str).unique().tolist())
    kam_pick = c1.selectbox("KAM", ["All"] + kams, key="fa_pb_kam")
    df = fa.copy()
    if kam_pick != "All":
        df = df[df["kam"].astype(str) == kam_pick]

    weeks_avail = sorted(df["week"].unique())
    cws_avail = [f"CW{w}" for w in weeks_avail]
    default_cws = cws_avail[-min(6, len(cws_avail)):] if cws_avail else []
    sel_cws = c2.multiselect("Select Week(s)", cws_avail, default=default_cws,
                              key="fa_pb_weeks")
    sel_w = [int(c.replace("CW", "")) for c in sel_cws]

    show_zero = c3.checkbox(
        "Include weeks with no commit", value=False, key="fa_pb_show_zero",
        help="OFF (default): only score weeks where the KAM actually committed "
             "VP for this buyer — that's the apples-to-apples FA view. "
             "ON: also include weeks where the buyer ordered but no commit "
             "exists (typical for past weeks before commits were recorded, "
             "and for catchall/Ostalo small-customer sales). FA % stays "
             "meaningful because it only scores `actual > 0` rows; turning "
             "this ON just surfaces under-committed volume.",
    )

    if sel_w:
        df = df[df["week"].isin(sel_w)]
    if not show_zero:
        df = df[df["forecast"] > 0]

    if len(df) == 0:
        st.info("No data for the current selection.")
        return

    # ---- Summary tiles ----
    tot_fc = int(df["forecast"].sum())
    tot_ac = int(df["actual"].sum())
    tot_var = tot_fc - tot_ac
    nz = df[df["actual"] > 0]
    fa_overall = (1 - (nz["forecast"] - nz["actual"]).abs().sum()
                  / max(nz["actual"].sum(), 1)) * 100 if len(nz) else 0.0
    fa_signed = (nz["forecast"].sum() / max(nz["actual"].sum(), 1)) * 100 if len(nz) else 0.0
    bias = ((nz["forecast"].sum() - nz["actual"].sum())
            / max(nz["actual"].sum(), 1)) * 100 if len(nz) else 0.0
    hit_pct = float(nz["hit"].mean() * 100) if len(nz) else 0.0

    m1, m2, m3, m4, m5 = st.columns(5)
    m1.metric("Commit (VP)", f"{tot_fc:,}")
    m2.metric("Actual (VP)", f"{tot_ac:,}", f"{tot_var:+,}")
    m3.metric("FA", f"{fa_overall:.1f}%",
              help="1 - |F-A|/A on totals across the selection (excludes zero-actual rows).")
    m4.metric("FA signed", f"{fa_signed:.1f}%",
              help="F/A on totals. <100% = under-commit, >100% = over-commit.")
    m5.metric("Hit rate", f"{hit_pct:.1f}%",
              help="% of (sku, week, buyer) rows within ±30% of actual.")
    st.caption(f"BIAS {bias:+.1f}% · {df['sku'].nunique()} SKUs · "
               f"{df.groupby(['kam','buyer']).ngroups} (KAM, buyer) pairs · "
               f"{int(len(nz)):,} non-zero-actual rows / {len(df):,} total")

    # ---- Per-buyer breakdown ----
    st.markdown("**By (KAM, buyer)**")
    brk_rows = []
    for (k, b), grp in df.groupby(["kam", "buyer"], dropna=False):
        gf = int(grp["forecast"].sum())
        ga = int(grp["actual"].sum())
        gnz = grp[grp["actual"] > 0]
        if len(gnz) and gnz["actual"].sum() > 0:
            gfa = (1 - (gnz["forecast"] - gnz["actual"]).abs().sum() / gnz["actual"].sum()) * 100
            gfas = gnz["forecast"].sum() / gnz["actual"].sum() * 100
            gbias = (gnz["forecast"].sum() - gnz["actual"].sum()) / gnz["actual"].sum() * 100
            ghit = float(gnz["hit"].mean() * 100)
        else:
            gfa = gfas = gbias = ghit = None
        brk_rows.append({
            "KAM": k, "Buyer": b,
            "SKUs": int(grp["sku"].nunique()),
            "Commit": gf, "Actual": ga, "Variance": gf - ga,
            "FA %": round(gfa, 1) if gfa is not None else None,
            "FA signed %": round(gfas, 1) if gfas is not None else None,
            "BIAS %": round(gbias, 1) if gbias is not None else None,
            "Hit %": round(ghit, 1) if ghit is not None else None,
        })
    brk_df = pd.DataFrame(brk_rows).sort_values(
        ["KAM", "Actual"], ascending=[True, False]
    ).reset_index(drop=True)
    st.dataframe(brk_df, use_container_width=True, hide_index=True)
    _fa_xlsx_download(brk_df, "⬇️ Per-buyer breakdown (xlsx)",
                       "FA_per_buyer.xlsx", key="fa_pb_dl_brk")

    # ---- SKU-week drilldown ----
    st.markdown("**Drilldown — SKU x week for a selected buyer**")
    buyer_pairs = sorted({f"{r['KAM']} → {r['Buyer']}" for _, r in brk_df.iterrows()})
    if buyer_pairs:
        pick = st.selectbox("(KAM, Buyer)", buyer_pairs, key="fa_pb_pick_buyer")
        sel_kam, sel_buyer = pick.split(" → ", 1)
        det = df[(df["kam"].astype(str) == sel_kam)
                  & (df["buyer"].astype(str) == sel_buyer)].copy()
        det = det.sort_values(["year", "week", "sku"]).reset_index(drop=True)
        if len(det) == 0:
            st.caption("Nothing to drill into for this buyer in the selected weeks.")
        else:
            det["FA %"] = det["fa"].apply(lambda v: round(v * 100, 1) if pd.notna(v) else None)
            det["FA signed %"] = det["fa_signed"].apply(
                lambda v: round(v * 100, 1) if pd.notna(v) else None)
            show = det[["cw_label", "sku", "oznaka", "cat",
                         "forecast", "actual", "variance", "FA %", "FA signed %"]].rename(
                columns={
                    "cw_label": "CW", "sku": "SKU", "oznaka": "Tier",
                    "cat": "Category", "forecast": "Commit",
                    "actual": "Actual", "variance": "Variance",
                })
            st.dataframe(show, use_container_width=True, hide_index=True)
            _fa_xlsx_download(show, "⬇️ Drilldown (xlsx)",
                               f"FA_per_buyer_{sel_kam}_{sel_buyer}.xlsx",
                               key="fa_pb_dl_det")


def _render_fa_tab(fa_data, caption, key_prefix):
    """Render the shared FA body (filters, weekly table, monthly table,
    top-errors drill-down) inside a single tab, keyed uniquely by key_prefix."""
    if fa_data is None or len(fa_data) == 0:
        st.info("No overlapping forecast vs actual data for this view yet.")
        return

    st.caption(caption)

    # --- Filters (rendered first so metrics reflect the active view) ---
    c1, c_xyz, c2, c3 = st.columns([1.2, 1.2, 2, 1.2])
    ozn_opts = ["All"] + sorted(fa_data["oznaka"].dropna().astype(str).unique().tolist())
    ozn_filter = c1.selectbox("Oznaka tier", ozn_opts, key=f"{key_prefix}_ozn_tier")

    # XYZ multiselect (default = all classes selected → no-op filter).
    # SKUs without XYZ classification surface as "N/A".
    xyz_present = sorted(fa_data["xyz"].dropna().astype(str).unique().tolist()) if "xyz" in fa_data.columns else []
    xyz_filter = c_xyz.multiselect(
        "XYZ class",
        options=xyz_present,
        default=xyz_present,
        key=f"{key_prefix}_xyz",
        help="X = stable (CV<0.5), Y = moderate (0.5–1.0), Z = erratic (CV>1.0). "
             "Z-class structurally caps achievable FA — keep it visible but don't blame the model.",
    )

    df = fa_data.copy()
    if ozn_filter != "All":
        df = df[df["oznaka"] == ozn_filter]
    if xyz_filter and xyz_present and len(xyz_filter) < len(xyz_present):
        df = df[df["xyz"].astype(str).isin(xyz_filter)]
    if len(df) == 0:
        st.info("No data for selected filter.")
        return
    tier_label = ozn_filter if ozn_filter != "All" else "ALL TIERS"

    available_weeks = sorted(df["week"].unique())
    available_cws = [f"CW{w}" for w in available_weeks]
    selected_cws = c2.multiselect("Select Week(s)", available_cws,
                                   default=available_cws[-min(4, len(available_cws)):],
                                   key=f"{key_prefix}_week_sel")
    selected_week_nums = [int(c.replace("CW", "")) for c in selected_cws]
    wk_df_full = df[df["week"].isin(selected_week_nums)]

    excl_n = c3.number_input(
        "Exclude top-N SKUs",
        min_value=0, max_value=50, value=0, step=1,
        key=f"{key_prefix}_excl_n",
        help="Removes the worst-offender SKUs (by aggregated abs error across "
             "the selected weeks) from ALL metrics and tables below.",
    )

    # Aggregate per-SKU error across selected weeks to identify worst offenders
    if len(wk_df_full) > 0:
        sku_err = (wk_df_full.groupby("sku", as_index=False)
                              .agg(abs_error=("error", "sum"),
                                   actual=("actual", "sum"),
                                   forecast=("forecast", "sum"))
                              .sort_values("abs_error", ascending=False))
        excluded_skus = set(sku_err.head(int(excl_n))["sku"].tolist()) if excl_n > 0 else set()
    else:
        sku_err = None
        excluded_skus = set()

    wk_df = wk_df_full[~wk_df_full["sku"].isin(excluded_skus)] if excluded_skus else wk_df_full

    # --- Summary metrics (reflect tier + week + exclusion) ---
    # Weekly view uses PER-WEEK AVERAGE across selected weeks (director rule):
    # compute FA/FA signed/BIAS per week first, then mean across weeks.
    # Monthly view keeps totals-based (implemented further down).
    tot_fc = int(wk_df["forecast"].sum())
    tot_ac = int(wk_df["actual"].sum())
    tot_var = tot_fc - tot_ac
    tot_err = int(wk_df["error"].sum())
    n_skus = wk_df["sku"].nunique()

    def _per_week_avg_metrics(d):
        """Return (fa_avg, fa_signed_avg, bias_avg, n_weeks_counted)."""
        if len(d) == 0:
            return 0.0, 0.0, 0.0, 0
        wk_totals = d.groupby("week", as_index=False).agg(
            ac=("actual", "sum"), fc=("forecast", "sum"))
        wk_totals = wk_totals[wk_totals["ac"] > 0]
        if len(wk_totals) == 0:
            return 0.0, 0.0, 0.0, 0
        fa_list = (1 - (wk_totals["fc"] - wk_totals["ac"]).abs() / wk_totals["ac"]).clip(lower=0) * 100
        fa_signed_list = (wk_totals["fc"] / wk_totals["ac"]) * 100
        bias_list = (wk_totals["fc"] - wk_totals["ac"]) / wk_totals["ac"] * 100
        return float(fa_list.mean()), float(fa_signed_list.mean()), float(bias_list.mean()), len(wk_totals)

    fa_overall, fa_signed_overall, bias_overall, n_weeks_used = _per_week_avg_metrics(wk_df)

    m1, m2, m3, m4, m5 = st.columns(5)
    m1.metric("Actuals", f"{tot_ac:,}")
    m2.metric("Forecast", f"{tot_fc:,}", f"{tot_var:+,}")
    m3.metric("FA signed", f"{fa_signed_overall:.1f}%",
              help=f"Average of per-week FA signed across {n_weeks_used} week(s). "
                   "Each week: forecast / actual. <100% = under-forecast. >100% = over-forecast.")
    m4.metric("BIAS", f"{bias_overall:+.1f}%",
              help="Average of per-week BIAS. Each week: (forecast − actual) / actual.")
    m5.metric("SKUs", f"{n_skus}")
    st.caption(f"Weekly metrics = average across {n_weeks_used} selected week(s). "
               "Monthly (below) uses sum-then-divide.")

    if excluded_skus:
        fa_orig_avg, _, _, _ = _per_week_avg_metrics(wk_df_full)
        st.caption(
            f"Excluded {len(excluded_skus)} SKU(s): {', '.join(sorted(excluded_skus))} "
            f"· FA without exclusion would be **{fa_orig_avg:.1f}%** "
            f"(Δ {fa_overall - fa_orig_avg:+.1f} pp)"
        )

    # ==== WEEKLY ====
    st.subheader("Weekly accuracy")
    if len(wk_df) > 0:
        wk_table = _build_fa_table(wk_df, tier_label, agg_mode="per_week_avg")
        _render_fa_table(wk_table, key=f"{key_prefix}_wk")
        _fa_xlsx_download(wk_table, "⬇️ Download weekly table (xlsx)",
                          f"FA_weekly_{key_prefix}.xlsx",
                          key=f"{key_prefix}_dl_weekly")

        with st.expander("Top SKUs ruining FA (aggregated across selected weeks)"):
            if sku_err is not None and len(sku_err) > 0:
                meta_cols = ["cat", "oznaka"]
                meta = wk_df_full.drop_duplicates("sku").set_index("sku")[meta_cols]
                ranked = sku_err.head(15).copy()
                ranked = ranked.join(meta, on="sku")
                ranked["FA%"] = (np.maximum(0, 1 - (ranked["forecast"] - ranked["actual"]).abs() / ranked["actual"].clip(lower=1)) * 100).round(1)
                ranked["FA signed%"] = (ranked["forecast"] / ranked["actual"].clip(lower=1) * 100).round(1)
                ranked["Bias%"] = ((ranked["forecast"] - ranked["actual"]) / ranked["actual"].clip(lower=1) * 100).round(1)
                ranked["Share of total err"] = (ranked["abs_error"] / max(wk_df_full["error"].sum(), 1) * 100).round(1)
                ranked = ranked.rename(columns={
                    "sku": "SKU", "cat": "Category", "oznaka": "Tier",
                    "actual": "Actual", "forecast": "Forecast",
                    "abs_error": "Abs Error",
                })[["SKU", "Category", "Tier", "Actual", "Forecast",
                    "Abs Error", "FA%", "FA signed%", "Bias%", "Share of total err"]]
                st.dataframe(ranked, use_container_width=True, hide_index=True)
                _fa_xlsx_download(ranked, "⬇️ Download top errors (xlsx)",
                                  f"FA_top_errors_{key_prefix}.xlsx",
                                  key=f"{key_prefix}_dl_errors")
    else:
        st.info("No data for selected weeks")

    # ==== STRUCTURAL BREAKDOWN (Tier + XYZ) ====
    # Same per-week-avg rule as the headline metrics so the slices are
    # comparable to the global numbers above. Bronze + Z drag here is
    # structural (low volume / high variability), not model failure.
    if len(wk_df) > 0:
        st.divider()
        st.subheader("Breakdown by structural class")
        st.caption(
            "Per-week-average across the selected weeks, grouped two ways. "
            "Use these to separate model performance from structural noise: "
            "Bronze/Z classes have an irreducible variance floor."
        )

        def _breakdown(group_col, label):
            present = sorted(wk_df[group_col].dropna().astype(str).unique().tolist())
            rows = []
            for g in present:
                sub = wk_df[wk_df[group_col].astype(str) == g]
                if len(sub) == 0:
                    continue
                fa_g, fa_signed_g, bias_g, n_wk = _per_week_avg_metrics(sub)
                hit_g = float(sub["hit"].mean() * 100) if "hit" in sub.columns and len(sub) else 0.0
                rows.append({
                    label: g,
                    "SKU-weeks": int(len(sub)),
                    "SKUs": int(sub["sku"].nunique()),
                    "FA %": round(fa_g, 1),
                    "FA signed %": round(fa_signed_g, 1),
                    "BIAS %": round(bias_g, 1),
                    "Hit Rate %": round(hit_g, 1),
                })
            return pd.DataFrame(rows)

        bcol1, bcol2 = st.columns(2)
        with bcol1:
            st.markdown("**By tier (oznaka)**")
            tier_tbl = _breakdown("oznaka", "Tier")
            if len(tier_tbl):
                st.dataframe(tier_tbl, use_container_width=True, hide_index=True)
                _fa_xlsx_download(tier_tbl, "⬇️ Tier breakdown (xlsx)",
                                  f"FA_tier_breakdown_{key_prefix}.xlsx",
                                  key=f"{key_prefix}_dl_tier_brk")
            else:
                st.caption("No tier data.")

        with bcol2:
            st.markdown("**By XYZ class**")
            if "xyz" in wk_df.columns:
                xyz_tbl = _breakdown("xyz", "XYZ")
                if len(xyz_tbl):
                    st.dataframe(xyz_tbl, use_container_width=True, hide_index=True)
                    _fa_xlsx_download(xyz_tbl, "⬇️ XYZ breakdown (xlsx)",
                                      f"FA_xyz_breakdown_{key_prefix}.xlsx",
                                      key=f"{key_prefix}_dl_xyz_brk")
                    st.caption("Z structurally caps FA. Compare X vs Y to see "
                               "where the model has room; Z is variance-driven.")
                else:
                    st.caption("No XYZ data.")
            else:
                st.caption("XYZ classification not available — run `compute_xyz.py`.")

    # ==== MONTHLY ====
    st.divider()
    st.subheader("Monthly accuracy (S&OP)")
    available_months = sorted(df["month"].unique(),
                               key=lambda m: _MONTH_ORDER.get(m, 99))
    if available_months:
        c1m, c2m = st.columns([2, 2])
        sel_month = c1m.selectbox("Select Month", available_months,
                                   index=len(available_months)-1,
                                   key=f"{key_prefix}_month_sel",
                                   help="Choose month for S&OP review")
        excl_n_mo = c2m.number_input(
            "Exclude top-N SKUs (monthly)",
            min_value=0, max_value=50, value=0, step=1,
            key=f"{key_prefix}_excl_n_mo",
            help="Removes the worst-offender SKUs (by aggregated abs error in "
                 "the selected month) from monthly metrics and table.",
        )

        mo_df_full = df[df["month"] == sel_month]
        if len(mo_df_full) > 0:
            # Show which ISO weeks map to this month (Monday-based mapping) so
            # the user can see the actual date range covered and spot partials.
            weeks_in_month = (mo_df_full[["year", "week"]]
                              .drop_duplicates()
                              .sort_values(["year", "week"]))
            today = datetime.now().date()
            parts, leaks, incomplete = [], False, False
            for _, r in weeks_in_month.iterrows():
                y, w = int(r["year"]), int(r["week"])
                try:
                    mon = datetime.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u").date()
                    sun = datetime.strptime(f"{y}-W{w:02d}-7", "%G-W%V-%u").date()
                except Exception:
                    continue
                tag = f"W{w} ({mon.day}.{mon.month}.–{sun.day}.{sun.month}.)"
                if sun.month != mon.month:
                    tag += "*"
                    leaks = True
                if sun >= today:
                    tag += "†"
                    incomplete = True
                parts.append(tag)
            if parts:
                caption = f"**{sel_month}** = " + " + ".join(parts)
                notes = []
                if leaks:
                    notes.append("\\* tjedan čiji dani prelaze u sljedeći mjesec — po ISO konvenciji cijeli pripada ovom mjesecu")
                if incomplete:
                    notes.append("† tjedan još nije gotov — actuali mogu biti parcijalni")
                if notes:
                    caption += "  \n" + "  \n".join(notes)
                st.markdown(caption)

            sku_err_mo = (mo_df_full.groupby("sku", as_index=False)
                                     .agg(abs_error=("error", "sum"),
                                          actual=("actual", "sum"),
                                          forecast=("forecast", "sum"))
                                     .sort_values("abs_error", ascending=False))
            excluded_mo = (set(sku_err_mo.head(int(excl_n_mo))["sku"].tolist())
                           if excl_n_mo > 0 else set())
            mo_df = mo_df_full[~mo_df_full["sku"].isin(excluded_mo)] if excluded_mo else mo_df_full

            # Monthly summary metrics (mirror weekly block)
            mo_fc = int(mo_df["forecast"].sum())
            mo_ac = int(mo_df["actual"].sum())
            mo_var = mo_fc - mo_ac
            mo_err = int(mo_df["error"].sum())
            # FA derived from totals (consistent with FA signed): |bias|/actual.
            fa_mo = max(0, (1 - abs(mo_var) / mo_ac) * 100) if mo_ac > 0 else 0
            fa_signed_mo = (mo_fc / mo_ac * 100) if mo_ac > 0 else 0
            bias_mo = (mo_var / mo_ac * 100) if mo_ac > 0 else 0
            n_skus_mo = mo_df["sku"].nunique()

            mm1, mm2, mm3, mm4, mm5 = st.columns(5)
            mm1.metric("Actuals", f"{mo_ac:,}")
            mm2.metric("Forecast", f"{mo_fc:,}", f"{mo_var:+,}")
            mm3.metric("FA signed", f"{fa_signed_mo:.1f}%",
                       help="forecast / actual. <100% = under-forecast (sold more than planned). "
                            ">100% = over-forecast. 100% = perfect.")
            mm4.metric("BIAS", f"{bias_mo:+.1f}%")
            mm5.metric("SKUs", f"{n_skus_mo}")

            if excluded_mo:
                ta_orig = mo_df_full["actual"].sum()
                tf_orig = mo_df_full["forecast"].sum()
                fa_orig = max(0, (1 - abs(tf_orig - ta_orig) / ta_orig) * 100) if ta_orig > 0 else 0
                st.caption(
                    f"Excluded {len(excluded_mo)} SKU(s): {', '.join(sorted(excluded_mo))} "
                    f"· FA without exclusion would be **{fa_orig:.1f}%** "
                    f"(Δ {fa_mo - fa_orig:+.1f} pp)"
                )

            mo_table = _build_fa_table(mo_df, tier_label)
            _render_fa_table(mo_table, key=f"{key_prefix}_mo_{sel_month}")
            _fa_xlsx_download(mo_table, "⬇️ Download monthly table (xlsx)",
                              f"FA_monthly_{key_prefix}_{sel_month}.xlsx",
                              key=f"{key_prefix}_dl_monthly")

            with st.expander(f"Top SKUs ruining FA in {sel_month}"):
                if len(sku_err_mo) > 0:
                    meta_cols = ["cat", "oznaka"]
                    meta = mo_df_full.drop_duplicates("sku").set_index("sku")[meta_cols]
                    ranked = sku_err_mo.head(15).copy()
                    ranked = ranked.join(meta, on="sku")
                    ranked["FA%"] = (np.maximum(0, 1 - (ranked["forecast"] - ranked["actual"]).abs() / ranked["actual"].clip(lower=1)) * 100).round(1)
                    ranked["FA signed%"] = (ranked["forecast"] / ranked["actual"].clip(lower=1) * 100).round(1)
                    ranked["Bias%"] = ((ranked["forecast"] - ranked["actual"]) / ranked["actual"].clip(lower=1) * 100).round(1)
                    ranked["Share of total err"] = (ranked["abs_error"] / max(mo_df_full["error"].sum(), 1) * 100).round(1)
                    ranked = ranked.rename(columns={
                        "sku": "SKU", "cat": "Category", "oznaka": "Tier",
                        "actual": "Actual", "forecast": "Forecast",
                        "abs_error": "Abs Error",
                    })[["SKU", "Category", "Tier", "Actual", "Forecast",
                        "Abs Error", "FA%", "FA signed%", "Bias%", "Share of total err"]]
                    st.dataframe(ranked, use_container_width=True, hide_index=True)
                    _fa_xlsx_download(ranked, "⬇️ Download top errors (xlsx)",
                                      f"FA_top_errors_monthly_{key_prefix}_{sel_month}.xlsx",
                                      key=f"{key_prefix}_dl_errors_mo")
        else:
            st.info(f"No data for {sel_month}")


def _render_sku_drilldown(sc, fa_total):
    """Per-SKU root-cause analysis — shows stat baseline, VP on-top, total
    forecast vs total / wholesale / retail actuals, plus attribution per week.
    """
    st.subheader("🔍 Per-SKU drill-down — root-cause analysis")
    st.caption(
        "Pick a SKU to see exactly where the forecast missed — "
        "was it the stat baseline (driving retail) or the VP commitment "
        "(driving wholesale)?"
    )

    if fa_total is None or sc is None or len(fa_total) == 0:
        st.info("No backtest data available — run a backtest first.")
        return

    # ---- Aggregate per-SKU to sort by total absolute error ----
    agg = fa_total.copy()
    if "error" not in agg.columns:
        agg["error"] = np.abs(agg["forecast"] - agg["actual"])
    sku_agg = (agg.groupby("sku")
                  .agg(total_err=("error", "sum"),
                       total_actual=("actual", "sum"),
                       n_weeks=("week", "count"))
                  .reset_index()
                  .sort_values("total_err", ascending=False))

    # ---- Build display labels with name + cumulative error ----
    name_map, cat_map, ozn_map = {}, {}, {}
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    if plan_csv.exists():
        plan = pd.read_csv(plan_csv)
        name_map = dict(zip(plan["sku"], plan["name"]))
        cat_map = dict(zip(plan["sku"], plan["cat"]))
        ozn_map = dict(zip(plan["sku"], plan["oznaka"]))

    options = []
    option_to_sku = {}
    for _, row in sku_agg.iterrows():
        code = row["sku"]
        name = (name_map.get(code, "") or "")[:45]
        label = f"{code} · {name} · abs.err Σ={int(row['total_err']):,} ({int(row['n_weeks'])}w)"
        options.append(label)
        option_to_sku[label] = code

    sel = st.selectbox(
        "Select SKU (sorted by total absolute error — most problematic first)",
        [""] + options,
        key="drill_sku_sel",
    )
    if not sel:
        return

    sku = option_to_sku[sel]

    # ---- Build per-week breakdown ----
    sku_fa = agg[agg["sku"] == sku].copy()
    if len(sku_fa) == 0:
        st.info("No weekly data for this SKU.")
        return

    # Merge channel-split actuals from sales_clean.csv
    sc_sku = sc[sc["sku"] == sku]
    sc_wk = (sc_sku.groupby(["year", "week"])
                   .agg(qty_wholesale=("qty_wholesale", "sum"),
                        qty_retail=("qty_retail", "sum"),
                        qty_webshop=("qty_webshop", "sum"))
                   .reset_index())
    sku_fa = sku_fa.merge(sc_wk, on=["year", "week"], how="left").fillna(0)

    # VP on-top history for this SKU
    vp_hist = _collect_vp_history()
    sku_fa["vp_on_top"] = sku_fa.apply(
        lambda r: vp_hist.get((r["sku"], int(r["year"]), int(r["week"])), 0.0),
        axis=1,
    )

    # v3.6: if the new channel-split backtest columns are available, use them
    # as the real stat_retail / stat_wholesale baselines. Otherwise fall back
    # to "forecast" as stat_baseline (old behavior).
    has_channel_split = (
        "forecast_retail" in sku_fa.columns
        and "forecast_wholesale" in sku_fa.columns
    )

    if has_channel_split:
        # Real per-channel stat forecasts from the new backtest
        sku_fa["stat_retail"] = sku_fa["forecast_retail"].astype(int)
        sku_fa["stat_wholesale"] = sku_fa["forecast_wholesale"].astype(int)
        # Stat baseline for the old-style columns: total stat = retail + wholesale
        # (OR the original "forecast" if channel_mode was 'total')
        sku_fa["stat_baseline_total"] = (
            sku_fa["stat_retail"] + sku_fa["stat_wholesale"]
        )
        # Build "Total forecast" = stat_retail + (stat_wholesale + VP on-top)
        # For split-mode SKUs this is the correct forecast; for total-mode
        # SKUs we keep using the raw 'forecast' as baseline.
        ch_mode_col = sku_fa.get("channel_mode", pd.Series(["total"]*len(sku_fa)))
        is_split = (ch_mode_col == "split")
        sku_fa["stat_baseline"] = np.where(
            is_split, sku_fa["stat_baseline_total"], sku_fa["forecast"]
        ).astype(int)
    else:
        # Legacy: approximate stat_retail as baseline, stat_wholesale as 0
        sku_fa["stat_retail"] = 0
        sku_fa["stat_wholesale"] = 0
        sku_fa["stat_baseline"] = sku_fa["forecast"].astype(int)

    sku_fa["vp_on_top"] = sku_fa["vp_on_top"].astype(int)
    sku_fa["total_forecast"] = sku_fa["stat_baseline"] + sku_fa["vp_on_top"]
    sku_fa["actual_total"] = sku_fa["actual"].astype(int)
    sku_fa["qty_wholesale"] = sku_fa["qty_wholesale"].astype(int)
    sku_fa["retail_actual"] = (sku_fa["qty_retail"] + sku_fa["qty_webshop"]).astype(int)

    # Channel-specific errors
    if has_channel_split:
        # Real per-channel comparison
        sku_fa["stat_err"] = sku_fa["stat_retail"] - sku_fa["retail_actual"]
        sku_fa["vp_err"] = (
            (sku_fa["stat_wholesale"] + sku_fa["vp_on_top"]) - sku_fa["qty_wholesale"]
        )
    else:
        # Legacy: stat_err is stat_baseline vs retail (imperfect but best available)
        sku_fa["stat_err"] = sku_fa["stat_baseline"] - sku_fa["retail_actual"]
        sku_fa["vp_err"] = sku_fa["vp_on_top"] - sku_fa["qty_wholesale"]
    sku_fa["total_err_signed"] = sku_fa["total_forecast"] - sku_fa["actual_total"]

    # Attribution
    def _attribute(row):
        se = abs(row["stat_err"])
        ve = abs(row["vp_err"])
        total = max(row["actual_total"], 1)
        # "Spot on" = combined residual < 5% of actual
        if (se + ve) < 0.05 * total:
            return "✓ Spot on"
        # No VP commitment and no wholesale activity → stat is solely responsible
        if row["vp_on_top"] == 0 and row["qty_wholesale"] == 0:
            return "Stat over" if row["stat_err"] > 0 else "Stat under"
        # Otherwise pick whichever channel missed more (1.5× margin for clarity)
        if se > ve * 1.5:
            return "Stat over" if row["stat_err"] > 0 else "Stat under"
        if ve > se * 1.5:
            return "VP over" if row["vp_err"] > 0 else "VP under"
        return "Mixed"
    sku_fa["attribution"] = sku_fa.apply(_attribute, axis=1)
    sku_fa = sku_fa.sort_values(["year", "week"])

    # ---- Header ----
    sku_name = name_map.get(sku, "")
    cat = cat_map.get(sku, "")
    ozn = ozn_map.get(sku, "")
    st.markdown(f"### {sku}" + (f" — {sku_name}" if sku_name else ""))
    st.caption(f"{cat}  ·  {ozn}  ·  {len(sku_fa)} weeks with data")

    # ---- Summary metrics ----
    tot_fc = int(sku_fa["total_forecast"].sum())
    tot_ac = int(sku_fa["actual_total"].sum())
    tot_err = int(sku_fa["total_err_signed"].abs().sum())
    # FA from totals (consistent with FA signed): |bias|/actual.
    fa_pct = max(0, (1 - abs(tot_fc - tot_ac) / tot_ac) * 100) if tot_ac > 0 else 0
    bias_pct = ((tot_fc - tot_ac) / tot_ac * 100) if tot_ac > 0 else 0

    # Channel mix across all history for this SKU (not just the weeks
    # shown in the drill-down) — gives the user context for interpretation
    sc_sku_all = sc[sc["sku"] == sku]
    tot_r = float(sc_sku_all["qty_retail"].sum())
    tot_w = float(sc_sku_all["qty_webshop"].sum())
    tot_ws = float(sc_sku_all["qty_wholesale"].sum())
    tot_ch = tot_r + tot_w + tot_ws
    ws_share = (tot_ws / tot_ch * 100) if tot_ch > 0 else 0
    retail_share = (tot_r / tot_ch * 100) if tot_ch > 0 else 0
    web_share = (tot_w / tot_ch * 100) if tot_ch > 0 else 0

    # Classify SKU by dominance
    if ws_share >= 80:
        mix_tag = "🏪 Wholesale-dominant"
    elif ws_share >= 50:
        mix_tag = "🏪 Wholesale-leaning"
    elif ws_share >= 20:
        mix_tag = "⚖️ Mixed channels"
    else:
        mix_tag = "🛍️ Retail-dominant"

    # Pull XYZ classification from sku_plan_list.csv (written by compute_xyz.py)
    xyz_tag = ""
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    if plan_csv.exists():
        pl = pd.read_csv(plan_csv)
        pl_row = pl[pl["sku"] == sku]
        if len(pl_row) and "ws_xyz" in pl.columns:
            ws_class = str(pl_row["ws_xyz"].iloc[0])
            ws_cv_val = pl_row.get("ws_cv", pd.Series([None])).iloc[0]
            xyz_label = {"X": "stable wholesale (X)",
                         "Y": "variable wholesale (Y)",
                         "Z": "lumpy wholesale (Z)",
                         "-": "no wholesale history"}.get(ws_class, ws_class)
            cv_str = f" · CV={ws_cv_val:.2f}" if pd.notna(ws_cv_val) else ""
            xyz_tag = f" · {xyz_label}{cv_str}"

    st.markdown(
        f"**{mix_tag}**{xyz_tag} — historical channel mix: "
        f"**{ws_share:.0f}%** wholesale · "
        f"**{retail_share:.0f}%** retail · "
        f"**{web_share:.0f}%** webshop"
    )

    m1, m2, m3, m4 = st.columns(4)
    m1.metric("Total forecast", f"{tot_fc:,}", f"{tot_fc - tot_ac:+,}")
    m2.metric("Total actual", f"{tot_ac:,}")
    m3.metric("FA", f"{fa_pct:.1f}%")
    m4.metric("Bias", f"{bias_pct:+.1f}%")

    # ---- Chart: stacked forecast bars + actual lines ----
    labels = [f"CW{int(w)}" for w in sku_fa["week"]]
    fig = go.Figure()

    if has_channel_split and (sku_fa.get("channel_mode", pd.Series()).eq("split")).any():
        # 3-component stacked bar: stat_retail, stat_wholesale, VP on-top
        fig.add_trace(go.Bar(
            x=labels, y=sku_fa["stat_retail"],
            name="Stat retail", marker_color="#C5D9F1",
            text=[f"{int(v):,}" if v > 0 else "" for v in sku_fa["stat_retail"]],
            textposition="inside", textfont=dict(size=9),
        ))
        fig.add_trace(go.Bar(
            x=labels, y=sku_fa["stat_wholesale"],
            name="Stat wholesale", marker_color="#9AC3DE",
            text=[f"{int(v):,}" if v > 0 else "" for v in sku_fa["stat_wholesale"]],
            textposition="inside", textfont=dict(size=9),
        ))
        fig.add_trace(go.Bar(
            x=labels, y=sku_fa["vp_on_top"],
            name="VP on-top", marker_color="#2F5496",
            text=[f"{int(v):,}" if v > 0 else "" for v in sku_fa["vp_on_top"]],
            textposition="inside", textfont=dict(size=9, color="white"),
        ))
    else:
        # Legacy 2-component stack
        fig.add_trace(go.Bar(
            x=labels, y=sku_fa["stat_baseline"],
            name="Stat baseline", marker_color="#9AC3DE",
            text=[f"{int(v):,}" if v > 0 else "" for v in sku_fa["stat_baseline"]],
            textposition="inside", textfont=dict(size=9),
        ))
        fig.add_trace(go.Bar(
            x=labels, y=sku_fa["vp_on_top"],
            name="VP on-top", marker_color="#2F5496",
            text=[f"{int(v):,}" if v > 0 else "" for v in sku_fa["vp_on_top"]],
            textposition="inside", textfont=dict(size=9, color="white"),
        ))

    fig.add_trace(go.Scatter(
        x=labels, y=sku_fa["actual_total"],
        name="Total actual", mode="lines+markers+text",
        line=dict(color="#548235", width=3), marker=dict(size=8),
        text=[f"{int(v):,}" for v in sku_fa["actual_total"]],
        textposition="top center", textfont=dict(size=10, color="#548235"),
    ))
    fig.add_trace(go.Scatter(
        x=labels, y=sku_fa["qty_wholesale"],
        name="Wholesale actual", mode="lines+markers",
        line=dict(color="#C00000", width=2, dash="dash"), marker=dict(size=6),
    ))
    fig.update_layout(
        barmode="stack", height=440,
        margin=dict(l=40, r=20, t=20, b=40),
        legend=dict(orientation="h", y=1.08, x=0),
        yaxis=dict(tickformat=",", title="Quantity",
                   gridcolor="rgba(200,200,200,0.3)"),
        xaxis=dict(title="", tickangle=-30),
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
    )
    st.plotly_chart(fig, use_container_width=True)

    # ---- Detail table ----
    if has_channel_split and (sku_fa.get("channel_mode", pd.Series()).eq("split")).any():
        # Richer table: stat_retail and stat_wholesale separately (only
        # meaningful for split-mode SKUs)
        tbl = pd.DataFrame({
            "Week": labels,
            "Stat retail": sku_fa["stat_retail"].values,
            "Stat wholesale": sku_fa["stat_wholesale"].values,
            "VP on-top": sku_fa["vp_on_top"].values,
            "Total forecast": sku_fa["total_forecast"].values,
            "Total actual": sku_fa["actual_total"].values,
            "Wholesale actual": sku_fa["qty_wholesale"].values,
            "Retail+web actual": sku_fa["retail_actual"].values,
            "Stat err (retail)": sku_fa["stat_err"].astype(int).values,
            "VP+stat_ws err (ws)": sku_fa["vp_err"].astype(int).values,
            "Attribution": sku_fa["attribution"].values,
        })
        fmt_numeric_cols = [
            "Stat retail", "Stat wholesale", "VP on-top", "Total forecast",
            "Total actual", "Wholesale actual", "Retail+web actual",
        ]
        err_cols = ["Stat err (retail)", "VP+stat_ws err (ws)"]
    else:
        # Legacy: collapsed stat baseline only
        tbl = pd.DataFrame({
            "Week": labels,
            "Stat baseline": sku_fa["stat_baseline"].values,
            "VP on-top": sku_fa["vp_on_top"].values,
            "Total forecast": sku_fa["total_forecast"].values,
            "Total actual": sku_fa["actual_total"].values,
            "Wholesale actual": sku_fa["qty_wholesale"].values,
            "Retail+web actual": sku_fa["retail_actual"].values,
            "Stat err (vs retail)": sku_fa["stat_err"].astype(int).values,
            "VP err (vs wholesale)": sku_fa["vp_err"].astype(int).values,
            "Attribution": sku_fa["attribution"].values,
        })
        fmt_numeric_cols = [
            "Stat baseline", "VP on-top", "Total forecast",
            "Total actual", "Wholesale actual", "Retail+web actual",
        ]
        err_cols = ["Stat err (vs retail)", "VP err (vs wholesale)"]

    def _color_attr(v):
        if "Spot on" in v: return "color: #006100; font-weight: bold"
        if v.startswith("Stat"): return "color: #9C6500; font-weight: bold"
        if v.startswith("VP"): return "color: #C00000; font-weight: bold"
        if v == "Mixed": return "color: #666666"
        return ""

    def _color_err(v):
        if v > 0: return "color: #006100"
        if v < 0: return "color: #C00000"
        return ""

    fmt_map = {c: "{:,.0f}" for c in fmt_numeric_cols}
    fmt_map.update({c: "{:+,.0f}" for c in err_cols})

    styled = (tbl.style
              .applymap(_color_attr, subset=["Attribution"])
              .applymap(_color_err, subset=err_cols)
              .format(fmt_map)
              .hide(axis="index"))
    st.dataframe(styled, use_container_width=True, hide_index=True)

    # ---- Narrative ----
    with st.expander("📖 Per-week narrative — plain English"):
        st.caption(
            "Math note: total_err = stat_err + vp_err, where "
            "stat_err = stat baseline − (retail+web actual) and "
            "vp_err = VP on-top − wholesale actual. "
            "The stat baseline was trained on qty_total, so when VP is zero it "
            "represents the whole-SKU prediction; when VP is present it "
            "represents the 'no-surge' portion of demand."
        )
        for _, r in sku_fa.iterrows():
            w = int(r["week"])
            sb = int(r["stat_baseline"])
            vp = int(r["vp_on_top"])
            tf = int(r["total_forecast"])
            act = int(r["actual_total"])
            ws = int(r["qty_wholesale"])
            rt = int(r["retail_actual"])

            fc_desc = (f"stat baseline **{sb:,}** + VP on-top **{vp:,}** "
                       f"= total **{tf:,}**"
                       if vp > 0 else f"stat baseline **{sb:,}** (no VP)")
            act_desc = f"total **{act:,}** ({ws:,} wholesale + {rt:,} retail/web)"

            attr = r["attribution"]
            if "Spot on" in attr:
                verdict = "→ **✓ Spot on.**"
            elif attr == "Stat under":
                verdict = (f"→ **Stat baseline was lower than retail+web** "
                           f"(baseline {sb:,} vs retail+web {rt:,}, off by "
                           f"{abs(int(r['stat_err'])):,}).")
            elif attr == "Stat over":
                verdict = (f"→ **Stat baseline was higher than retail+web** "
                           f"(baseline {sb:,} vs retail+web {rt:,}, off by "
                           f"{abs(int(r['stat_err'])):,}).")
            elif attr == "VP under":
                if vp == 0:
                    verdict = (f"→ **No VP commitment despite {ws:,} in "
                               f"actual wholesale activity** — KAMs missed this.")
                else:
                    verdict = (f"→ **VP under-committed wholesale** — "
                               f"committed {vp:,} vs actual wholesale {ws:,}.")
            elif attr == "VP over":
                verdict = (f"→ **VP over-committed wholesale** — "
                           f"committed {vp:,} vs actual wholesale {ws:,}.")
            else:  # Mixed
                verdict = (f"→ **Both sides contributed** — stat off by "
                           f"{int(r['stat_err']):+,} on retail, "
                           f"VP off by {int(r['vp_err']):+,} on wholesale.")

            st.markdown(f"- **CW{w}** — Forecast: {fc_desc}. Actual: "
                        f"{act_desc}.  {verdict}")


def _bootstrap_historical_vp_snapshot():
    """UI to import filled KAM VP templates from a past week as a synthetic
    consensus snapshot. Reuses the same file parser as the regular combine
    flow so formats stay consistent.

    Writes to CONSENSUS_DIR / snapshot_bootstrap_{year}w{cw}_{ts}.json
    so it sorts alongside normal snapshots and the FA builder picks it up
    automatically. Does NOT touch vp_input.csv (that's for current/future
    weeks only).
    """
    with st.expander("📥 Bootstrap VP history from past KAM templates "
                     "(use this to import week-14 or earlier)"):
        st.caption(
            "Upload KAM-filled VP templates from a PAST cycle. Each upload "
            "creates one synthetic snapshot representing what VP committed "
            "to for that cycle's forecast horizon. Repeat for each past "
            "week you have data for (e.g. run it once for CW13's commitments, "
            "once for CW12's, etc.). VP history accumulates automatically."
        )

        c1, c2, c3 = st.columns([1, 1, 3])
        snap_cw = c1.number_input(
            "Snapshot CW", min_value=1, max_value=53, value=13,
            key="boot_snap_cw",
            help="The CW when this forecast would have been run. "
                 "If the templates have CW14 as the first column, "
                 "the snapshot CW is 13 (forecast runs against CW+1 onwards)."
        )
        snap_year = c2.number_input(
            "Year", min_value=2020, max_value=2035,
            value=datetime.now().year, key="boot_snap_year"
        )

        uploaded = c3.file_uploader(
            "Upload filled VP KAM templates (.xlsx) for this past cycle",
            type=["xlsx"], accept_multiple_files=True,
            key="boot_vp_upload",
        )

        if not uploaded:
            return

        from openpyxl import load_workbook as lw
        all_rows = []
        parse_errors = []
        for uf in uploaded:
            try:
                tmp_path = DATA_DIR / f"_tmp_bootstrap_{uf.name}"
                tmp_path.write_bytes(uf.getvalue())
                wb = lw(tmp_path, data_only=True)

                kam_name = "Unknown"
                if "_meta" in wb.sheetnames:
                    ws_m = wb["_meta"]
                    kam_name = ws_m.cell(1, 2).value or uf.name

                ws = wb.worksheets[0]

                file_cws = {}
                for c in range(6, 30):
                    v = ws.cell(4, c).value
                    if v and str(v).startswith("CW"):
                        file_cws[str(v)] = c
                    elif v is None:
                        break

                row_count = 0
                for r in range(5, ws.max_row + 1):
                    sku = ws.cell(r, 1).value
                    if not sku:
                        continue
                    rtype = ws.cell(r, 5).value or ""
                    row_data = {"sku": sku, "type": rtype, "kam": kam_name}
                    has_data = False
                    for cw_label, col in file_cws.items():
                        v = ws.cell(r, col).value
                        row_data[cw_label] = float(v) if v and v != "" else 0.0
                        if v and float(v) > 0:
                            has_data = True
                    if has_data:
                        all_rows.append(row_data)
                        row_count += 1

                wb.close()
                tmp_path.unlink(missing_ok=True)
                st.caption(f"✅ {uf.name} — {kam_name}: {row_count} rows with data")
            except Exception as e:
                parse_errors.append((uf.name, str(e)))
                st.warning(f"⚠️ {uf.name}: {e}")

        if not all_rows:
            if parse_errors:
                st.error("No data could be parsed from the uploaded files.")
            return

        combined = pd.DataFrame(all_rows)
        cw_cols = [c for c in combined.columns if str(c).startswith("CW")]
        # Dedupe rule: same SKU+type on multiple sheets (e.g. on Konzum AND
        # Spar) → take the MAX per CW, not the sum. Then sum (on-top demand +
        # regular increase) per SKU for the final commitment.
        if "type" in combined.columns:
            deduped = combined.groupby(["sku", "type"], as_index=False)[cw_cols].max()
            per_sku = deduped.groupby("sku", as_index=False)[cw_cols].sum()
        else:
            per_sku = combined.groupby("sku", as_index=False)[cw_cols].max()

        # Build the vp_inputs dict shape that snapshots store:
        # {sku: {CW_label: value}} — only positive values
        vp_inputs = {}
        for _, row in per_sku.iterrows():
            sku = row["sku"]
            per_cw = {}
            for c in cw_cols:
                v = float(row.get(c, 0) or 0)
                if v > 0:
                    per_cw[c] = v
            if per_cw:
                vp_inputs[sku] = per_cw

        total_units = int(sum(sum(v.values()) for v in vp_inputs.values()))
        st.divider()
        st.write(
            f"**Preview — {len(vp_inputs)} SKUs with commitments · "
            f"{total_units:,} total VP units across {len(cw_cols)} weeks**"
        )
        st.caption(f"Snapshot will be labeled: "
                   f"CW{int(snap_cw)}/{int(snap_year)} bootstrap from "
                   f"{len(uploaded)} file(s) · "
                   f"KAM(s): {', '.join(sorted(combined['kam'].unique()))}")

        preview_rows = []
        for sku, per_cw in list(vp_inputs.items())[:15]:
            preview_rows.append({"SKU": sku, **{c: int(per_cw.get(c, 0)) for c in cw_cols}})
        st.dataframe(pd.DataFrame(preview_rows), use_container_width=True, hide_index=True)
        if len(vp_inputs) > 15:
            st.caption(f"... and {len(vp_inputs) - 15} more SKUs")

        # Check for conflict with existing snapshots
        existing_boot = list(CONSENSUS_DIR.glob(
            f"snapshot_bootstrap_{int(snap_year)}w{int(snap_cw):02d}_*.json"
        ))
        if existing_boot:
            st.warning(
                f"⚠️ A bootstrap snapshot for CW{int(snap_cw)}/{int(snap_year)} "
                f"already exists ({len(existing_boot)} file(s)). Saving again "
                "will create a second one — the latest will win in FA. "
                "Delete old ones via the file system if needed."
            )

        if st.button("💾 Save as synthetic snapshot", type="primary",
                     key="boot_save_snap"):
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            # Timestamp string uses the snap's declared year — so
            # _collect_vp_history's year inference works correctly
            timestamp_display = f"{int(snap_year)}-01-01 00:00 (bootstrap)"
            snap = {
                "label": f"CW{int(snap_cw)}/{int(snap_year)} bootstrap ({len(uploaded)} files)",
                "timestamp": timestamp_display,
                "snap_year": int(snap_year),
                "cw": int(snap_cw),
                "n_skus": len(vp_inputs),
                "cws": cw_cols,
                "total_rev": 0,
                "rows": [],            # no forecast rows — this is VP-only
                "vp_inputs": vp_inputs,
                "mp_inputs": {},       # MP not bootstrapped here
                "_bootstrap": True,    # marker flag
            }
            filename = f"snapshot_bootstrap_{int(snap_year)}w{int(snap_cw):02d}_{ts}.json"
            (CONSENSUS_DIR / filename).write_text(json.dumps(snap))
            load_consensus_snapshots.clear()
            st.success(
                f"✅ Saved {filename} — {len(vp_inputs)} SKUs, "
                f"{total_units:,} units. Refresh the page to see "
                "wholesale accuracy populate."
            )


@st.cache_data(ttl=60)
def _load_factor_history_map():
    """Return {(sku, target_year, target_week): factor} from factor_history.csv.
    When multiple runs target the same week, keep the LATEST recorded factor
    (sorted by run_date) — that is the value the planner committed to most
    recently before the week closed.
    """
    path = DATA_DIR / "factor_history.csv"
    if not path.exists():
        return {}
    try:
        df = pd.read_csv(path)
    except Exception:
        return {}
    if len(df) == 0:
        return {}
    df = df.sort_values("run_date")
    df = df.drop_duplicates(subset=["sku", "target_year", "target_week"], keep="last")
    return {
        (r["sku"], int(r["target_year"]), int(r["target_week"])): float(r["factor"])
        for _, r in df.iterrows()
    }


def _apply_factor_to_fa(fa_df, factor_map):
    """Return a copy of fa_df with forecast multiplied by planner factor and
    derived metrics (error, fa, fa_signed, hit, variance) recomputed. Rows
    without a recorded factor get 1.00 (i.e., unchanged)."""
    if fa_df is None or len(fa_df) == 0 or not factor_map:
        return fa_df
    fa = fa_df.copy()
    keys = list(zip(fa["sku"], fa["year"].astype(int), fa["week"].astype(int)))
    factors = np.array([factor_map.get(k, 1.0) for k in keys], dtype=float)
    fa["forecast"] = (fa["forecast"].astype(float) * factors).round().astype(int)
    fa["error"] = np.abs(fa["forecast"] - fa["actual"])
    fa["variance"] = fa["forecast"] - fa["actual"]
    fa["fa"] = np.maximum(0, 1 - fa["error"] / fa["actual"].clip(lower=1))
    fa["fa_signed"] = fa["forecast"] / fa["actual"].clip(lower=1)
    fa["hit"] = (fa["error"] / fa["actual"].clip(lower=1) <= 0.3).astype(int)
    return fa


def page_forecast_accuracy():
    st.title("Forecast Accuracy Dashboard")
    cy, cw = get_current_cw()

    sc = load_sales_data()

    # Build all datasets up front (cheap — in-memory)
    fa_total = _build_fa_dataset("total", sc)
    fa_kam = _build_fa_dataset("kam_cm_projections", sc)
    fa_model_only = _build_fa_dataset("model_only", sc)
    fa_live = _build_live_fa_data(sc)

    # If nothing at all is available, show the bootstrap prompt (same as before)
    if fa_total is None and fa_kam is None and fa_model_only is None and fa_live is None:
        st.warning("No forecast accuracy data available yet.")
        st.info("Run a backtest to generate accuracy data for past weeks.")
        if st.button("Run walk-forward backtest (last 8 weeks)", type="primary"):
            _run_backtest_from_app()
        return

    # ---- Planner Factor toggle ----
    factor_map = _load_factor_history_map()
    factor_count = len(factor_map)
    pf_col, pf_info = st.columns([1, 4])
    apply_factor = pf_col.toggle(
        "Apply Planner Factor",
        value=False,
        key="fa_apply_factor",
        help="Multiply forecast by the planner factor that was set in the "
             "Demand Plan at run time. Toggle to compare 'model only' vs "
             "'model + planner adjustment'.",
    )
    if factor_count == 0:
        pf_info.caption("⚠️ No planner factor history yet. Run a forecast (factors "
                         "from the saved Demand Plan are snapshotted into "
                         "`data/factor_history.csv` per run). Until then this "
                         "toggle has no effect.")
    else:
        pf_info.caption(f"Factor history: {factor_count:,} (sku, week) entries. "
                         "When ON, the latest factor recorded before the week "
                         "closed is applied. Weeks without a recorded factor "
                         "default to 1.00.")
    if apply_factor and factor_count > 0:
        fa_total = _apply_factor_to_fa(fa_total, factor_map)
        fa_model_only = _apply_factor_to_fa(fa_model_only, factor_map)
        # KAM tab is on-top vs actual — factors don't apply, leave as-is.

    tab_total, tab_kam, tab_model, tab_live = st.tabs([
        "🌐 Global FA",
        "👥 KAM/CM projections FA",
        "🤖 Model-only FA (excl. KAM/CM)",
        "🟢 Live FA",
    ])

    with tab_total:
        _render_fa_tab(
            fa_total,
            caption="Total model forecast vs qty_total, across **all SKUs**. "
                    "Data comes from walk-forward backtest + forecast log.",
            key_prefix="fa_total",
        )

    with tab_kam:
        st.caption("On-top commitment vs actual sales **in that planner's own channel**. "
                   "VP → qty_wholesale (TRC/VPT/VPB/RAC docs). "
                   "MP → qty_retail (RCM docs). "
                   "Webshop isn't here because no planner owns it — it's tracked "
                   "as regular model-forecasted sales in Global FA and Model-only FA.")
        if fa_kam is None:
            st.info(
                "No KAM/CM projection accuracy data yet. "
                "VP/MP on-top values are captured every time a forecast run "
                "auto-saves a consensus snapshot. "
                "Once at least one snapshot has been saved **and** a week in "
                "its horizon has passed with actual sales recorded, data "
                "will appear here."
            )
            st.markdown("---")
            _bootstrap_historical_vp_snapshot()
        else:
            fa_per_buyer = _build_per_buyer_vp_fa(sc)
            per_buyer_count = len(fa_per_buyer) if fa_per_buyer is not None else 0
            sub_all, sub_both, sub_vp, sub_mp, sub_pb = st.tabs([
                f"All ({len(fa_kam):,})",
                f"Both VP + MP ({int((fa_kam['input_kind']=='both').sum()):,})",
                f"VP only ({int((fa_kam['input_kind']=='vp_only').sum()):,})",
                f"MP only ({int((fa_kam['input_kind']=='mp_only').sum()):,})",
                f"VP per buyer ({per_buyer_count:,})",
            ])
            with sub_all:
                _render_fa_tab(fa_kam, caption="All SKU-weeks with any VP or MP commitment.",
                               key_prefix="fa_kam_all")
            with sub_both:
                sub = fa_kam[fa_kam["input_kind"] == "both"]
                _render_fa_tab(sub if len(sub) else None,
                               caption="SKU-weeks with **both** VP and MP commitment.",
                               key_prefix="fa_kam_both")
            with sub_vp:
                sub = fa_kam[fa_kam["input_kind"] == "vp_only"]
                _render_fa_tab(sub if len(sub) else None,
                               caption="SKU-weeks with a **VP (wholesale)** commitment but no MP.",
                               key_prefix="fa_kam_vp")
            with sub_mp:
                sub = fa_kam[fa_kam["input_kind"] == "mp_only"]
                _render_fa_tab(sub if len(sub) else None,
                               caption="SKU-weeks with an **MP (retail)** commitment but no VP.",
                               key_prefix="fa_kam_mp")
            with sub_pb:
                _render_per_buyer_vp_fa(fa_per_buyer)
            st.markdown("---")
            _bootstrap_historical_vp_snapshot()

    with tab_model:
        _render_fa_tab(
            fa_model_only,
            caption="Model baseline vs actual, with the **planner-owned channel "
                    "stripped** for every SKU-week that had an on-top. "
                    "VP on-top → wholesale removed from both sides. "
                    "MP on-top → retail removed from both sides. "
                    "SKU-weeks with no on-top are kept as-is (total vs qty_total).",
            key_prefix="fa_model_only",
        )

    with tab_live:
        if fa_live is None:
            st.info(
                "Live FA needs `data/forecast_log.csv` — populated automatically "
                "every time `forecast_engine.py` runs. Run a forecast (Demand → "
                "Run forecast engine), let at least one of its target weeks close "
                "with actual sales recorded, and this tab will populate."
            )
        else:
            _render_fa_tab(
                fa_live,
                caption="**First** forecast made for each (SKU, week) — pulled "
                        "from `forecast_log.csv` (earliest run_date per target "
                        "week) vs `qty_total` actuals. Tracks how good the very "
                        "first commitment was, before any factor or KAM/CM tweaks "
                        "rolled in. The Planner Factor toggle does not apply here "
                        "— the logged forecast already includes whatever factor "
                        "was active at run time.",
                key_prefix="fa_live",
            )

    # ---- TOP 10 Impactors per top-3 category ----
    st.divider()
    _render_fa_top10_impactors(fa_total)

    # ---- Per-SKU drill-down ----
    st.divider()
    _render_sku_drilldown(sc, fa_total)

    # ---- Backtest button ----
    st.divider()
    c1b, c2b = st.columns([3, 1])
    n_bt = c1b.number_input("Backtest weeks", min_value=1, max_value=26, value=8, key="bt_weeks")
    if c2b.button("Re-run backtest", use_container_width=True):
        _run_backtest_from_app(int(n_bt))


_MONTH_ORDER = {"January":1,"February":2,"March":3,"April":4,"May":5,"June":6,
                "July":7,"August":8,"September":9,"October":10,"November":11,"December":12}


def _week_to_month_name(year, week):
    try:
        d = datetime.strptime(f'{year}-W{week:02d}-1', '%G-W%V-%u')
        return d.strftime("%B")
    except Exception:
        return "Unknown"


def _render_fa_top10_impactors(fa_total):
    """
    TOP 10 Impactors: worst-performing SKUs (by absolute forecast error) in
    each of the 3 biggest categories (by actual volume) for the selected week.
    Shows SKU, Name, Actual, Forecast, FA% per category.
    """
    st.subheader("FA TOP 10 Impactors")
    st.caption("Worst-performing SKUs (biggest absolute error) in each of the "
               "**3 largest categories by actual volume** for the chosen week.")

    if fa_total is None or len(fa_total) == 0:
        st.info("No FA data available.")
        return

    # Name lookup from plan (drill-down does the same).
    plan_csv = DATA_DIR / "sku_plan_list.csv"
    name_map = {}
    if plan_csv.exists():
        plan = pd.read_csv(plan_csv)
        name_map = dict(zip(plan["sku"], plan["name"]))

    weeks = sorted(fa_total["week"].unique().tolist())
    if not weeks:
        st.info("No weeks in FA data.")
        return
    cw_labels = [f"CW{w}" for w in weeks]

    sel = st.selectbox("Week", cw_labels, index=len(cw_labels) - 1,
                       key="fa_top10_week")
    sel_week = int(sel.replace("CW", ""))
    wk = fa_total[fa_total["week"] == sel_week].copy()
    if len(wk) == 0:
        st.info(f"No data for {sel}.")
        return

    # Top 3 categories by actual volume this week
    cat_vol = wk.groupby("cat", as_index=False)["actual"].sum().sort_values(
        "actual", ascending=False).head(3)
    top3_cats = cat_vol["cat"].tolist()
    if not top3_cats:
        st.info("No categories found for this week.")
        return

    cols = st.columns(len(top3_cats))
    for col, cat in zip(cols, top3_cats):
        sub = wk[wk["cat"] == cat].copy()
        sub["abs_err"] = (sub["forecast"] - sub["actual"]).abs()
        sub = sub.sort_values("abs_err", ascending=False).head(10)
        sub["Name"] = sub["sku"].map(name_map).fillna("")
        sub["FA%"] = (sub["fa"] * 100).round(1)
        out = sub[["sku", "Name", "actual", "forecast", "FA%"]].rename(columns={
            "sku": "SKU", "actual": "Actual", "forecast": "Forecast",
        })
        with col:
            cat_total_ac = int(wk[wk["cat"] == cat]["actual"].sum())
            st.markdown(f"**{cat}**  \n*{cat_total_ac:,} kom ukupno*")
            st.dataframe(
                out.style.format({
                    "Actual": "{:,.0f}",
                    "Forecast": "{:,.0f}",
                    "FA%": "{:.1f}%",
                }),
                use_container_width=True, hide_index=True,
            )


def _build_fa_table(df, tier_label, agg_mode="totals"):
    """
    agg_mode="totals"        → sum-then-divide (monthly convention).
    agg_mode="per_week_avg"  → compute per-week, then average across weeks
                                (weekly convention; director rule).

    For per-week-avg mode, FA/FA signed/BIAS per category = mean of that
    category's per-week metrics across selected weeks. Actuals/Forecast/
    Variance/Abs-error stay as sums (they're volumes, averaging is meaningless).
    Hit Rate stays as row-level mean (unchanged).
    """
    rows = []
    weeks_in_df = sorted(df["week"].unique()) if "week" in df.columns else []

    def _per_week_avg_for(grp):
        """Return (fa_avg, fa_signed_avg, bias_avg) averaged across weeks in grp."""
        if "week" not in grp.columns or len(grp) == 0:
            return 0.0, 0.0, 0.0
        wk = grp.groupby("week", as_index=False).agg(ac=("actual","sum"), fc=("forecast","sum"))
        wk = wk[wk["ac"] > 0]
        if len(wk) == 0:
            return 0.0, 0.0, 0.0
        fa_l  = (1 - (wk["fc"] - wk["ac"]).abs() / wk["ac"]).clip(lower=0) * 100
        fas_l = (wk["fc"] / wk["ac"]) * 100
        b_l   = (wk["fc"] - wk["ac"]) / wk["ac"] * 100
        return float(fa_l.mean()), float(fas_l.mean()), float(b_l.mean())

    for cat in sorted(df["cat"].dropna().unique()):
        grp = df[df["cat"] == cat]
        if len(grp) == 0:
            continue
        act = int(grp["actual"].sum())
        fc = int(grp["forecast"].sum())
        var = fc - act
        abs_err = int(grp["error"].sum())
        if agg_mode == "per_week_avg":
            fa, fa_signed, bias = _per_week_avg_for(grp)
        else:
            bias = (var / act * 100) if act > 0 else 0
            fa = max(0, (1 - abs(var) / act) * 100) if act > 0 else 0
            fa_signed = (fc / act * 100) if act > 0 else 0
        hr = grp["hit"].mean() * 100
        rows.append({"Grupacija": cat, "Actuals": act, "Forecast": fc, "Variance": var,
                      "Abs error": abs_err, "BIAS": round(bias, 1), "FA": round(fa, 1),
                      "FA signed": round(fa_signed, 1),
                      "Hit Rate": round(hr, 1)})
    if rows:
        ta = sum(r["Actuals"] for r in rows); tf = sum(r["Forecast"] for r in rows)
        tv = tf - ta; te = sum(r["Abs error"] for r in rows)
        if agg_mode == "per_week_avg":
            fa_tot, fas_tot, b_tot = _per_week_avg_for(df)
        else:
            b_tot = (tv/ta*100) if ta>0 else 0
            fa_tot = max(0,(1-abs(tv)/ta)*100) if ta>0 else 0
            fas_tot = (tf/ta*100) if ta>0 else 0
        rows.append({"Grupacija": f"{tier_label} SUMARNO", "Actuals": ta, "Forecast": tf,
                      "Variance": tv, "Abs error": te,
                      "BIAS": round(b_tot, 1),
                      "FA": round(fa_tot, 1),
                      "FA signed": round(fas_tot, 1),
                      "Hit Rate": round(df["hit"].mean()*100, 1)})
    return pd.DataFrame(rows)


def _render_fa_table(table_df, key=None):
    if table_df.empty:
        return
    raw = table_df.copy()

    # Compact/Full toggle — compact hides Variance, Abs error, FA (capped).
    compact = st.toggle("Sažeti prikaz", value=True, key=f"fa_compact_{key}" if key else None,
                         help="Sažeti: Actuals, Forecast, FA signed, BIAS, Hit Rate. "
                              "Prošireni: sve kolone.")
    if compact:
        compact_cols = ["Grupacija", "Actuals", "Forecast", "FA signed", "BIAS", "Hit Rate"]
        raw = raw[[c for c in compact_cols if c in raw.columns]]

    def color_bias(v):
        if v < -5: return "color: #C00000; font-weight: bold"
        if v > 5: return "color: #006600; font-weight: bold"
        return ""

    def color_fa(v):
        if v >= 70: return "background-color: #C6EFCE; color: #006100"
        if v >= 55: return "background-color: #FFEB9C; color: #9C6500"
        if v >= 40: return "background-color: #FCD5B4; color: #974706"
        return "background-color: #FFC7CE; color: #9C0006"

    def color_fa_signed(v):
        # Symmetric around 100% — bands match color_fa (30/45/60 pts from 100).
        d = abs(v - 100)
        if d <= 30: return "background-color: #C6EFCE; color: #006100"
        if d <= 45: return "background-color: #FFEB9C; color: #9C6500"
        if d <= 60: return "background-color: #FCD5B4; color: #974706"
        return "background-color: #FFC7CE; color: #9C0006"

    def color_hr(v):
        if v >= 60: return "background-color: #C6EFCE; color: #006100"
        if v >= 40: return "background-color: #FFEB9C; color: #9C6500"
        return "background-color: #FFC7CE; color: #9C0006"

    def style_fn(row):
        s = [""] * len(row)
        cols = list(row.index)
        for i, c in enumerate(cols):
            if c == "BIAS": s[i] = color_bias(row[c])
            elif c == "FA": s[i] = color_fa(row[c])
            elif c == "FA signed": s[i] = color_fa_signed(row[c])
            elif c == "Hit Rate": s[i] = color_hr(row[c])
            if str(row.get("Grupacija","")).endswith("SUMARNO"):
                s[i] += "; font-weight: bold; background-color: #D6E4F0" if s[i] else "font-weight: bold; background-color: #D6E4F0"
        return s

    fmt = {
        "Actuals": "{:,.0f}", "Forecast": "{:,.0f}", "Variance": "{:+,.0f}",
        "Abs error": "{:,.0f}", "BIAS": "{:+.1f}%", "FA": "{:.1f}%",
        "Hit Rate": "{:.1f}%",
    }
    if "FA signed" in raw.columns:
        fmt["FA signed"] = "{:.1f}%"
    styled = raw.style.apply(style_fn, axis=1).format(fmt).hide(axis="index")
    st.dataframe(styled, use_container_width=True, hide_index=True)


def _run_backtest_from_app(n_weeks=8):
    import copy as _copy
    script = Path("run_backtest.py").resolve()
    if not script.exists():
        st.error("run_backtest.py not found next to app.py")
        return
    env = _copy.copy(os.environ)
    env["PYTHONIOENCODING"] = "utf-8"
    with st.spinner(f"Running walk-forward backtest ({n_weeks} weeks)... This may take 2-5 minutes."):
        r = subprocess.run(
            ["python", str(script), str(n_weeks), str(DATA_DIR.resolve())],
            cwd=str(Path(".").resolve()),
            stdin=subprocess.DEVNULL,
            capture_output=True, text=True, timeout=900,
            env=env, encoding="utf-8", errors="replace"
        )
    if r.returncode == 0:
        st.success("Backtest complete!")
    else:
        st.error("Backtest failed")
    st.code(r.stdout[-3000:] + "\n" + r.stderr[-1000:])
    st.rerun()


# ============================================================================
# TOP 30 WHOLESALE WATCHLIST (v3.6)
# ============================================================================
# Ranked by trailing 26-week wholesale volume. These SKUs drive ~80% of
# wholesale volume (per diagnostic). Demand planner reviews manually each
# cycle — no KAM input collected for these, planner owns the forecast
# directly. Shows CHECK flag from forecast_engine's Review column so the
# planner sees at a glance which ones need attention.

WATCHLIST_SIGNOFF_PATH = DATA_DIR / "watchlist_signoff.json"


def _load_watchlist_signoff(week_key):
    """Load sign-off state for a given CW. Returns dict {sku: {signed, at, note}}."""
    if not WATCHLIST_SIGNOFF_PATH.exists():
        return {}
    try:
        all_so = json.loads(WATCHLIST_SIGNOFF_PATH.read_text())
        return all_so.get(week_key, {})
    except Exception:
        return {}


def _save_watchlist_signoff(week_key, signoffs):
    """Persist sign-off state for a given CW (merges with other CWs in file)."""
    all_so = {}
    if WATCHLIST_SIGNOFF_PATH.exists():
        try:
            all_so = json.loads(WATCHLIST_SIGNOFF_PATH.read_text())
        except Exception:
            pass
    all_so[week_key] = signoffs
    WATCHLIST_SIGNOFF_PATH.write_text(json.dumps(all_so, indent=2))


def _read_demand_plan_review_and_fc(plan_xlsx_path, skus):
    """Extract Review flag and next 4 weeks of Baseline FC forecast for a
    set of SKUs from the generated Polleo_Demand_Plan.xlsx.

    Structure (written by forecast_engine v3.6):
      - Sheet 'Demand Planning', headers at row 5
      - Block of 11 rows per SKU starting row 6
      - Row offsets within block (from row 6 = block start):
          +0: SKU header row (no Label)
          +1: Baseline FC        +2: Planner Factor
          +3: Adjusted FC        +4: VP on-top
          +5: VP regular         +6: MP on-top
          +7: MP regular         +8: On-top Total
          +9: TOTAL DEMAND       +10: blank separator
      - Columns: 1=SKU, 2=Artikl, 3=Grupacija, 4=Oznaka, 5=Label,
          6..31 = RR-25..RR-0 (26 run rate weeks),
          32..44 = FC+1..FC+13 (13 forecast weeks),
          45 = Review ('OK' | 'CHECK')
    """
    review_flags = {}
    next4_fc = {}
    if not plan_xlsx_path.exists():
        return review_flags, next4_fc
    try:
        from openpyxl import load_workbook
        wb = load_workbook(plan_xlsx_path, data_only=True, read_only=True)
        if "Demand Planning" not in wb.sheetnames:
            wb.close()
            return review_flags, next4_fc
        dp = wb["Demand Planning"]

        REVIEW_COL = 45
        FC_START_COL = 32      # FC+1 column
        BLOCK = 11
        START_ROW = 6
        # Read from Baseline FC row (block+1), which has hard-coded values
        # from the engine. TOTAL DEMAND (block+9) is formula-based and
        # openpyxl can't resolve formulas unless Excel has cached them.
        BASELINE_OFFSET = 1

        sku_set = set(skus)
        for row in dp.iter_rows(min_row=START_ROW, max_col=REVIEW_COL, values_only=False):
            r_num = row[0].row
            # Only process "block start" rows (every 11th)
            if (r_num - START_ROW) % BLOCK != 0:
                continue
            sku = row[0].value
            if not sku or sku not in sku_set:
                continue
            # Review flag on the header row
            rev = row[REVIEW_COL - 1].value
            review_flags[sku] = rev
            # Baseline FC row (block + 1) has the raw engine forecast
            bl_r = r_num + BASELINE_OFFSET
            fc_vals = []
            for c in range(FC_START_COL, FC_START_COL + 4):
                v = dp.cell(bl_r, c).value
                fc_vals.append(int(v) if isinstance(v, (int, float)) else 0)
            next4_fc[sku] = fc_vals
        wb.close()
    except Exception as e:
        st.warning(f"Could not read Demand Plan flags: {e}")
    return review_flags, next4_fc


def _compute_watchlist_data():
    """Build the top-30 watchlist dataframe. Returns (df, meta_dict) or (None, err_msg)."""
    sales_path = DATA_DIR / "sales_clean.csv"
    plan_path = DATA_DIR / "sku_plan_list.csv"

    if not sales_path.exists():
        return None, "sales_clean.csv missing. Run **Update sales** first."
    if not plan_path.exists():
        return None, "sku_plan_list.csv missing. Run forecast first (initializes the plan list)."

    sc = pd.read_csv(sales_path)
    plan = pd.read_csv(plan_path)
    plan_skus = set(plan["sku"])

    # Trailing 26-week window, defensively excluding any weeks past
    # the current calendar week (same logic as run_backtest.py).
    # sales_clean.csv *shouldn't* contain future weeks but has in the past.
    iso = datetime.now().isocalendar()
    cur_key = iso[0] * 100 + iso[1]
    all_yw = sc[["year", "week"]].drop_duplicates().sort_values(["year", "week"])
    all_yw["_key"] = all_yw["year"] * 100 + all_yw["week"]
    all_yw = all_yw[all_yw["_key"] < cur_key].drop(columns=["_key"])

    window = all_yw.tail(TRAILING_WEEKS).copy()
    window["_in_w"] = 1
    sc_w = sc.merge(window[["year", "week", "_in_w"]], on=["year", "week"], how="inner")
    sc_w = sc_w[sc_w["sku"].isin(plan_skus)]

    ws_vol = sc_w.groupby("sku")["qty_wholesale"].sum().reset_index()
    ws_vol.columns = ["sku", "ws_26w"]
    total_ws = ws_vol["ws_26w"].sum()

    # Top N by wholesale volume
    top = ws_vol.sort_values("ws_26w", ascending=False).head(WATCHLIST_SIZE).reset_index(drop=True)
    top["rank"] = top.index + 1
    top = top.merge(plan, on="sku", how="left")

    # Last 4 weeks wholesale actuals per SKU (ordered calendar)
    last4_weeks = all_yw.tail(4).copy()
    last4_weeks["_in_l4"] = 1
    sc_l4 = sc.merge(last4_weeks[["year", "week", "_in_l4"]],
                     on=["year", "week"], how="inner")
    sc_l4 = sc_l4[sc_l4["sku"].isin(top["sku"])]

    # Build per-SKU ordered arrays
    last4_by_sku = {}
    l4_list = list(zip(last4_weeks["year"].astype(int), last4_weeks["week"].astype(int)))
    for sku in top["sku"]:
        sub = sc_l4[sc_l4["sku"] == sku]
        lookup = {(int(r["year"]), int(r["week"])): int(r["qty_wholesale"])
                  for _, r in sub.iterrows()}
        last4_by_sku[sku] = [lookup.get(yw, 0) for yw in l4_list]
    top["last4_ws"] = top["sku"].map(last4_by_sku)
    top["last4_ws_avg"] = top["last4_ws"].apply(
        lambda x: sum(x) / len(x) if x else 0
    )

    # Read Review flag + next 4 FC from Demand Plan xlsx
    review_flags, next4_fc = _read_demand_plan_review_and_fc(
        OUTPUT_FILE, top["sku"].tolist()
    )
    top["review"] = top["sku"].map(lambda s: review_flags.get(s, None))
    top["next4_fc"] = top["sku"].map(lambda s: next4_fc.get(s, [0, 0, 0, 0]))
    top["next4_fc_avg"] = top["next4_fc"].apply(
        lambda x: sum(x) / len(x) if x else 0
    )

    # Change indicator: next4 avg vs last4 avg
    def _chg(row):
        a = row["last4_ws_avg"]
        b = row["next4_fc_avg"]
        if a > 0:
            return (b - a) / a * 100
        return float("nan") if b == 0 else 999.0  # no baseline

    top["chg_pct"] = top.apply(_chg, axis=1)

    meta = {
        "total_ws_26w": int(total_ws),
        "top_ws_26w": int(top["ws_26w"].sum()),
        "pct_of_total": (top["ws_26w"].sum() / total_ws * 100) if total_ws > 0 else 0,
        "n_check": int((top["review"] == "CHECK").sum()),
        "n_no_plan": int(top["review"].isna().sum()),
        "last4_week_labels": [f"CW{w}" for _, w in l4_list],
    }
    return top, meta


def page_top30_watchlist():
    st.title("🎯 Top 30 wholesale watchlist")
    st.caption(
        "Top 30 SKUs by trailing 26-week wholesale volume. Demand planner "
        "reviews these weekly as a focused attention list — **KAMs still "
        "receive the full SKU list in their templates**; this is a review "
        "layer on top. Look for CHECK flags (forecast ±25% off recent run "
        "rate) and large next-vs-last swings."
    )

    data = _compute_watchlist_data()
    if data[0] is None:
        st.warning(data[1])
        return
    top, meta = data

    cy, cw = get_current_cw()
    week_key = f"{cy}w{cw:02d}"
    signoffs = _load_watchlist_signoff(week_key)
    top["signed"] = top["sku"].map(lambda s: bool(signoffs.get(s, {}).get("signed", False)))

    # ---- Summary metrics ----
    c1, c2, c3, c4 = st.columns(4)
    c1.metric("Top 30 wholesale volume (26w)",
              f"{meta['top_ws_26w']:,}",
              f"{meta['pct_of_total']:.0f}% of all wholesale")
    c2.metric("⚠️ Flagged CHECK",
              f"{meta['n_check']} / {WATCHLIST_SIZE}",
              delta_color="inverse")
    signed_n = int(top["signed"].sum())
    c3.metric(f"Reviewed ({week_key})",
              f"{signed_n} / {WATCHLIST_SIZE}",
              delta_color="normal")
    no_plan = meta["n_no_plan"]
    c4.metric("No forecast yet",
              f"{no_plan}",
              delta_color="off",
              help="SKUs missing from Polleo_Demand_Plan.xlsx — run forecast.")

    st.divider()

    # ---- Filter toggle ----
    cols = st.columns([1, 1, 1, 2])
    filter_mode = cols[0].radio(
        "Filter", ["All 30", "CHECK only", "Unreviewed only"],
        horizontal=False, key="wl_filter", label_visibility="collapsed"
    )
    sort_mode = cols[1].radio(
        "Sort", ["Rank (volume)", "Change % (big swings first)",
                 "CHECK first"],
        horizontal=False, key="wl_sort", label_visibility="collapsed"
    )

    view = top.copy()
    if filter_mode == "CHECK only":
        view = view[view["review"] == "CHECK"]
    elif filter_mode == "Unreviewed only":
        view = view[~view["signed"]]

    if sort_mode == "Change % (big swings first)":
        view = view.reindex(view["chg_pct"].abs().sort_values(ascending=False).index)
    elif sort_mode == "CHECK first":
        view = view.assign(_ck=(view["review"] == "CHECK").astype(int)) \
                   .sort_values(["_ck", "rank"], ascending=[False, True]) \
                   .drop(columns=["_ck"])

    if len(view) == 0:
        st.info("No rows match the current filter.")
        return

    # ---- Build display dataframe for st.data_editor ----
    # Mini sparkline: last 4 actual + next 4 forecast (8 points)
    def _combine_trail(row):
        return list(row["last4_ws"]) + list(row["next4_fc"])

    display = pd.DataFrame({
        "#": view["rank"].astype(int).values,
        "SKU": view["sku"].values,
        "Tier": view.get("oznaka", pd.Series(["—"]*len(view))).fillna("—").values,
        "XYZ": view.get("ws_xyz", pd.Series(["—"]*len(view))).fillna("—").values,
        "Name": view["name"].fillna("").values,
        "26w ws": view["ws_26w"].astype(int).values,
        "Trail (last 4 → next 4)": [_combine_trail(r) for _, r in view.iterrows()],
        "Last 4 avg": view["last4_ws_avg"].round(0).astype(int).values,
        "Next 4 avg": view["next4_fc_avg"].round(0).astype(int).values,
        "Δ %": view["chg_pct"].round(0).values,
        "Flag": view["review"].fillna("—").values,
        "Reviewed": view["signed"].values,
    })

    edited = st.data_editor(
        display,
        column_config={
            "#": st.column_config.NumberColumn("#", width="small", disabled=True),
            "SKU": st.column_config.TextColumn("SKU", width="small", disabled=True),
            "Tier": st.column_config.TextColumn("Tier", width="small", disabled=True),
            "XYZ": st.column_config.TextColumn("XYZ", width="small", disabled=True,
                                                help="Wholesale-channel variability: X=stable, Y=variable, Z=lumpy"),
            "Name": st.column_config.TextColumn("Name", width="medium", disabled=True),
            "26w ws": st.column_config.NumberColumn(
                "26w ws", width="small", disabled=True, format="%d"
            ),
            "Trail (last 4 → next 4)": st.column_config.LineChartColumn(
                "Trajectory",
                width="medium",
                help="Left 4 points: last 4 weeks of wholesale actuals. Right 4 points: next 4 weeks of Baseline FC (raw engine forecast) from Demand Plan.",
            ),
            "Last 4 avg": st.column_config.NumberColumn(
                "Last 4 avg", width="small", disabled=True, format="%d"
            ),
            "Next 4 avg": st.column_config.NumberColumn(
                "Next 4 avg", width="small", disabled=True, format="%d"
            ),
            "Δ %": st.column_config.NumberColumn(
                "Δ %", width="small", disabled=True, format="%+.0f%%",
                help="Next 4 avg vs last 4 avg. >±25% = engine would flag CHECK."
            ),
            "Flag": st.column_config.TextColumn(
                "Flag", width="small", disabled=True,
                help="Review flag from forecast_engine: CHECK if forecast avg ±25% off run rate avg."
            ),
            "Reviewed": st.column_config.CheckboxColumn(
                "Reviewed",
                default=False,
                help=f"Sign off that you've reviewed this SKU for {week_key}."
            ),
        },
        hide_index=True,
        use_container_width=True,
        key=f"watchlist_editor_{week_key}",
        num_rows="fixed",
    )

    # ---- Persist sign-off changes ----
    changed_any = False
    for _, row in edited.iterrows():
        sku = row["SKU"]
        new_sig = bool(row["Reviewed"])
        prev_sig = bool(signoffs.get(sku, {}).get("signed", False))
        if new_sig != prev_sig:
            if new_sig:
                signoffs[sku] = {
                    "signed": True,
                    "at": datetime.now().isoformat(timespec="minutes"),
                }
            else:
                signoffs.pop(sku, None)
            changed_any = True
    if changed_any:
        _save_watchlist_signoff(week_key, signoffs)

    st.caption(
        f"Sign-offs are saved per CW in `data/watchlist_signoff.json`. "
        f"Click **Reviewed** after reviewing the SKU in Forecast Accuracy drill-down "
        f"or the Demand Plan. Unchecking clears the sign-off for this week."
    )

    # ---- Drill-down jump helper ----
    st.divider()
    col_a, col_b = st.columns([1, 3])
    sel_sku = col_a.selectbox(
        "Drill into SKU:",
        options=[""] + view["sku"].tolist(),
        key="wl_drill_sku"
    )
    if sel_sku:
        col_b.info(
            f"Jump to **Forecast accuracy** → type `{sel_sku}` in the "
            f"SKU search to see the channel-split drill-down for this SKU."
        )

    # ---- Footer notes ----
    with st.expander("ℹ️ How this watchlist works"):
        st.markdown(f"""
**Logic**
- **Top 30** is ranked by total wholesale units over the last 26 weeks (from `sales_clean.csv`).
- **Trajectory** shows last 4 weeks of wholesale actuals followed by next 4 weeks of **Baseline FC** (raw engine forecast, pre-planner-factor, pre-VP/MP) from `Polleo_Demand_Plan.xlsx`. We read Baseline FC (not TOTAL DEMAND) because TOTAL DEMAND is formula-based in the xlsx — Baseline is the hard-coded engine output.
- **Δ %** is next-4-avg vs last-4-avg. Engine flags **CHECK** when this exceeds ±25%.
- **Flag** is the same Review flag shown in the Demand Plan Excel (column 45).

**Workflow**
1. Review each SKU's trajectory and flag
2. If something looks off (big Δ, unexpected CHECK, flat forecast against rising actuals), open Forecast Accuracy → search the SKU → drill down
3. Adjust via Demand Planning → Planner Factor, or VP input if structural
4. Re-run forecast. Watchlist refreshes automatically.
5. Tick **Reviewed** to sign off for this week (CW{cw}).

**Why this watchlist exists?**
These 30 SKUs represent ~80% of wholesale volume. They benefit from direct planner review each cycle — look at the trajectory, check flags, drill down if anything's off. KAMs still receive the **full** SKU list in their templates as usual; this watchlist is a review layer *on top*, not a replacement.

**Data sources**
- `sales_clean.csv` — wholesale volume, last 4 weeks actuals
- `sku_plan_list.csv` — name, category, oznaka, `ws_xyz` class
- `Polleo_Demand_Plan.xlsx` — next 4 weeks forecast + Review flag
- `watchlist_signoff.json` — sign-off state per CW (auto-created)
""")


def page_consensus_plan():
    st.title("Consensus plan")
    st.caption("Lock and version your demand plan each S&OP cycle.")

    cy, cw = get_current_cw()
    plan = load_demand_plan()

    # Save new snapshot
    st.subheader("Save current plan")
    if plan is None:
        st.warning("No forecast generated yet.")
    else:
        c1, c2 = st.columns([3, 1])
        label = c1.text_input("Snapshot label", value=f"CW{cw} S&OP lock", key="snap_label")
        if c2.button("💾 Save snapshot", type="primary", use_container_width=True):
            fname = save_consensus_snapshot(label)
            if fname:
                st.success(f"Saved: {fname}")
                st.rerun()

    # List snapshots
    st.divider()
    st.subheader("Saved snapshots")
    snapshots = load_consensus_snapshots()

    if not snapshots:
        st.info("No snapshots saved yet. Save your first one above.")
        return

    for i, snap in enumerate(snapshots):
        c1, c2, c3 = st.columns([4, 1, 1])
        c1.markdown(f"**{snap['label']}**  \n"
                    f"<small style='color:gray;'>{snap['timestamp']} — {snap['n_skus']} SKUs, "
                    f"€{snap['total_rev']:,} rev</small>", unsafe_allow_html=True)
        if i == 0:
            c2.caption("✅ Latest")
        if c3.button("🗑️", key=f"del_snap_{i}"):
            (CONSENSUS_DIR / snap["filename"]).unlink(missing_ok=True)
            st.rerun()

    # Compare two snapshots
    if len(snapshots) >= 2:
        st.divider()
        st.subheader("Compare snapshots")
        labels = [f"{s['label']} ({s['timestamp']})" for s in snapshots]
        c1, c2 = st.columns(2)
        idx_a = c1.selectbox("Snapshot A", range(len(labels)), format_func=lambda i: labels[i], key="cmp_a")
        idx_b = c2.selectbox("Snapshot B", range(len(labels)), index=min(1, len(labels)-1),
                             format_func=lambda i: labels[i], key="cmp_b")

        if st.button("Compare", type="primary"):
            a, b = snapshots[idx_a], snapshots[idx_b]
            st.metric("Revenue change",
                      f"€{a['total_rev']:,} → €{b['total_rev']:,}",
                      f"{b['total_rev'] - a['total_rev']:+,}")
            st.metric("SKU count", f"{a['n_skus']} → {b['n_skus']}",
                      f"{b['n_skus'] - a['n_skus']:+}")

            # SKU-level diff
            a_skus = {r["SKU"] for r in a.get("rows", [])}
            b_skus = {r["SKU"] for r in b.get("rows", [])}
            added = b_skus - a_skus
            removed = a_skus - b_skus
            if added:
                st.caption(f"Added: {len(added)} SKUs")
            if removed:
                st.caption(f"Removed: {len(removed)} SKUs")


def _render_promo_one_slider():
    """One-slider S&OP report: biggest promotions in a chosen month, with
    forecasted revenue and RUC. Includes a downloadable PPTX file with the
    same data laid out cleanly for a single slide."""
    st.subheader("📑 Promotions one-slider")
    st.caption("Biggest planned promotions by category for the chosen month — "
               "ranked by total on-top units (VP wholesale + MP retail combined). "
               "Download produces a black + Polleo-blue branded slide. "
               "Drop a `data/sop_template.pptx` to inherit your full corporate "
               "master (pentagon background, lion logo) — the slide is added "
               "on top of it.")

    # --- Build month options from VP + MP CSV CW columns ---
    from datetime import datetime as _dt, timedelta as _td

    def _load_csv_cws(fname):
        p = DATA_DIR / fname
        if not p.exists():
            return None
        df = pd.read_csv(p)
        return df

    vp_df = _load_csv_cws("vp_input.csv")
    mp_df = _load_csv_cws("mp_input.csv")
    if vp_df is None and mp_df is None:
        st.info("No VP / MP inputs uploaded yet.")
        return

    iso = _dt.now().isocalendar()
    cur_y, cur_w = int(iso[0]), int(iso[1])

    cw_to_yw = {}
    for df in [d for d in (vp_df, mp_df) if d is not None]:
        for c in df.columns:
            if not str(c).startswith("CW"):
                continue
            try:
                w = int(str(c).replace("CW", ""))
            except ValueError:
                continue
            y = cur_y if w >= cur_w else cur_y + 1
            cw_to_yw[c] = (y, w)

    if not cw_to_yw:
        st.info("VP / MP CSVs have no CW columns.")
        return

    month_to_cws = {}
    for cw_label, (y, w) in cw_to_yw.items():
        try:
            d = _dt.strptime(f"{y}-W{w:02d}-1", "%G-W%V-%u")
        except ValueError:
            continue
        m = d.strftime("%B %Y")
        month_to_cws.setdefault(m, []).append(cw_label)

    months_ordered = sorted(month_to_cws.keys(),
                             key=lambda m: _dt.strptime(m, "%B %Y"))

    # Default to May (whichever year contains it in the data) if present.
    default_idx = 0
    for i, m in enumerate(months_ordered):
        if m.startswith("May"):
            default_idx = i
            break

    c1, c2, c3 = st.columns([2, 1, 1])
    sel_month = c1.selectbox("Month", months_ordered, index=default_idx, key="promo_month")
    top_n = c2.number_input("Top N", min_value=5, max_value=30, value=10, step=1,
                             key="promo_topn")
    rank_by = c3.selectbox("Rank by", ["Revenue €", "RUC €", "Units"],
                            key="promo_rank")

    target_cws = month_to_cws[sel_month]
    sku_prices = load_sku_prices()

    # RUC per unit (blended) — from sales_clean recent weeks.
    sc = load_sales_data()
    ruc_per_unit = {}
    if sc is not None and "ruc_total" in sc.columns:
        recent_yw = sc[["year", "week"]].drop_duplicates().sort_values(
            ["year", "week"]).tail(8)
        keys = set(recent_yw["year"].astype(int) * 100 + recent_yw["week"].astype(int))
        yw_key = sc["year"].astype(int) * 100 + sc["week"].astype(int)
        sc_recent = sc[yw_key.isin(keys)]
        for sku, grp in sc_recent.groupby("sku"):
            tq = float(grp["qty_total"].sum())
            tr = float(grp["ruc_total"].sum())
            ruc_per_unit[sku] = tr / tq if tq > 0 else 0

    # Aggregate per-SKU on-top units across selected CWs.
    rows_out = []
    plan_meta = pd.read_csv(DATA_DIR / "sku_plan_list.csv") if (DATA_DIR / "sku_plan_list.csv").exists() else None
    name_map = dict(zip(plan_meta["sku"], plan_meta["name"])) if plan_meta is not None else {}
    cat_map = dict(zip(plan_meta["sku"], plan_meta["cat"])) if plan_meta is not None else {}

    sku_set = set()
    if vp_df is not None:
        sku_set |= set(vp_df["sku"].astype(str))
    if mp_df is not None:
        sku_set |= set(mp_df["sku"].astype(str))

    def _sum_for(df, sku, cws):
        if df is None:
            return 0.0
        m = df["sku"].astype(str) == sku
        if not m.any():
            return 0.0
        row = df[m].iloc[0]
        s = 0.0
        for c in cws:
            if c in row.index:
                try:
                    s += float(row[c] or 0)
                except (TypeError, ValueError):
                    pass
        return s

    for sku in sku_set:
        vp_units = _sum_for(vp_df, sku, target_cws)
        mp_units = _sum_for(mp_df, sku, target_cws)
        total_units = vp_units + mp_units
        if total_units <= 0:
            continue
        price = float(sku_prices.get(sku, 0))
        ruc_rate = float(ruc_per_unit.get(sku, 0))
        rev = total_units * price
        ruc = total_units * ruc_rate
        rows_out.append({
            "SKU": sku,
            "Name": (name_map.get(sku, "") or "")[:60],
            "Category": cat_map.get(sku, "") or "",
            "VP units": int(round(vp_units)),
            "MP units": int(round(mp_units)),
            "Total units": int(round(total_units)),
            "Revenue €": round(rev, 0),
            "RUC €": round(ruc, 0),
        })

    if not rows_out:
        st.info(f"No on-top commitments found for {sel_month}.")
        return

    df_out = pd.DataFrame(rows_out)
    rank_col = {"Revenue €": "Revenue €", "RUC €": "RUC €", "Units": "Total units"}[rank_by]
    df_out = df_out.sort_values(rank_col, ascending=False).head(int(top_n)).reset_index(drop=True)

    # Headline numbers
    total_rev = float(df_out["Revenue €"].sum())
    total_ruc = float(df_out["RUC €"].sum())
    total_units = int(df_out["Total units"].sum())

    m1, m2, m3, m4 = st.columns(4)
    m1.metric("Promo SKUs", len(df_out))
    m2.metric("Total units", f"{total_units:,}")
    m3.metric("Revenue €", f"€{total_rev:,.0f}")
    m4.metric("RUC €", f"€{total_ruc:,.0f}")

    st.dataframe(
        df_out.style.format({
            "VP units": "{:,.0f}", "MP units": "{:,.0f}",
            "Total units": "{:,.0f}",
            "Revenue €": "€{:,.0f}", "RUC €": "€{:,.0f}",
        }),
        use_container_width=True, hide_index=True,
    )

    # --- PPTX export ---
    try:
        pptx_bytes = _build_promo_pptx(sel_month, df_out, total_units, total_rev, total_ruc, target_cws)
    except Exception as e:
        pptx_bytes = None
        st.caption(f"⚠️ PPTX build failed: {e}")
    if pptx_bytes:
        st.download_button(
            "📥 Download S&OP slide (.pptx)",
            data=pptx_bytes,
            file_name=f"SOP_Promotions_{sel_month.replace(' ', '_')}.pptx",
            mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
            type="primary",
        )


def _build_promo_pptx(month_label, df, total_units, total_rev, total_ruc, cws):
    """Build a one-slide PPTX in Polleo brand style: black bg, white title,
    Polleo-blue accent. If `data/sop_template.pptx` exists, that template is
    used (so the slide picks up the user's pentagon background + lion logo);
    otherwise a clean black slide is generated from scratch.
    """
    from pptx import Presentation
    from pptx.util import Inches, Pt, Emu
    from pptx.dml.color import RGBColor
    from pptx.enum.shapes import MSO_SHAPE
    from pptx.enum.text import PP_ALIGN
    import io

    # Polleo brand palette (sampled from the template screenshot)
    BG_BLACK    = RGBColor(0x0A, 0x0A, 0x0A)   # near-black background
    POLLEO_BLUE = RGBColor(0x2A, 0x9B, 0xD1)   # accent blue
    WHITE       = RGBColor(0xFF, 0xFF, 0xFF)
    LIGHT_GRAY  = RGBColor(0xCC, 0xCC, 0xCC)
    CARD_DARK   = RGBColor(0x1A, 0x1A, 0x1A)   # slightly lighter than bg
    ROW_DARK    = RGBColor(0x14, 0x14, 0x14)
    ROW_DARK2   = RGBColor(0x1F, 0x1F, 0x1F)

    template = DATA_DIR / "sop_template.pptx"
    if template.exists():
        # User-supplied Polleo template — keeps their master slide bg & logo.
        prs = Presentation(str(template))
        # Use blank layout if available, else fall back to first.
        layout = next((l for l in prs.slide_layouts if "blank" in l.name.lower()),
                       prs.slide_layouts[-1] if len(prs.slide_layouts) >= 1 else prs.slide_layouts[0])
        slide = prs.slides.add_slide(layout)
        # Don't overlay a black rectangle — the template already has the look.
        bg_overlay = False
    else:
        prs = Presentation()
        prs.slide_width = Inches(13.33)
        prs.slide_height = Inches(7.5)
        slide = prs.slides.add_slide(prs.slide_layouts[6])
        # Full-bleed black background
        bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0,
                                      prs.slide_width, prs.slide_height)
        bg.line.fill.background()
        bg.fill.solid()
        bg.fill.fore_color.rgb = BG_BLACK
        bg_overlay = True

    sw, sh = prs.slide_width, prs.slide_height

    # ---- Title (white, centered) ----
    title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3),
                                          sw - Inches(1.0), Inches(0.8))
    tf = title_box.text_frame
    tf.word_wrap = True
    p = tf.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    r = p.add_run()
    r.text = f"Planned promotions  ·  {month_label}"
    r.font.size = Pt(32)
    r.font.bold = True
    r.font.color.rgb = WHITE
    r.font.name = "Calibri"

    # ---- Subtitle (Polleo blue, centered) ----
    sub = slide.shapes.add_textbox(Inches(0.5), Inches(1.1),
                                     sw - Inches(1.0), Inches(0.5))
    p = sub.text_frame.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    cw_str = ", ".join(sorted(cws, key=lambda c: int(c[2:])))
    r = p.add_run()
    r.text = f"Coverage: {cw_str}  —  forecasted VP + MP commitments"
    r.font.size = Pt(15)
    r.font.bold = True
    r.font.color.rgb = POLLEO_BLUE
    r.font.name = "Calibri"

    # ---- KPI cards (4 wide) ----
    kpi_y = Inches(1.85)
    kpi_h = Inches(1.05)
    cards_w = sw - Inches(0.8)
    card_w = Emu(int(cards_w / 4))
    gap = Inches(0.1)
    kpis = [
        ("SKUs", f"{len(df):,}"),
        ("Units", f"{total_units:,}"),
        ("Revenue", f"€{total_rev:,.0f}"),
        ("RUC", f"€{total_ruc:,.0f}"),
    ]
    for i, (label, val) in enumerate(kpis):
        x = Inches(0.4) + Emu(i * int(card_w))
        card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, kpi_y,
                                        card_w - gap, kpi_h)
        card.fill.solid()
        card.fill.fore_color.rgb = CARD_DARK
        card.line.color.rgb = POLLEO_BLUE
        card.line.width = Pt(1.5)
        tf = card.text_frame
        tf.margin_left = Inches(0.18)
        tf.margin_top = Inches(0.08)
        p1 = tf.paragraphs[0]
        r1 = p1.add_run()
        r1.text = label
        r1.font.size = Pt(12)
        r1.font.bold = True
        r1.font.color.rgb = POLLEO_BLUE
        r1.font.name = "Calibri"
        p2 = tf.add_paragraph()
        r2 = p2.add_run()
        r2.text = val
        r2.font.size = Pt(26)
        r2.font.bold = True
        r2.font.color.rgb = WHITE
        r2.font.name = "Calibri"

    # ---- Table ----
    cols = ["SKU", "Name", "Category", "Units", "Revenue €", "RUC €"]
    n_rows = len(df) + 1
    table_left = Inches(0.4)
    table_top = Inches(3.10)
    table_width = sw - Inches(0.8)
    # Cap height so the slide doesn't overflow on large Top N.
    avail_h = sh - Inches(3.5) - Inches(0.4)
    table_height = min(Inches(4.2), avail_h)
    table_shape = slide.shapes.add_table(n_rows, len(cols),
                                          table_left, table_top,
                                          table_width, table_height)
    tbl = table_shape.table
    widths_in = [1.2, 4.5, 2.4, 1.3, 1.55, 1.55]
    for i, w in enumerate(widths_in):
        tbl.columns[i].width = Inches(w)

    # Header — Polleo blue
    for c, name in enumerate(cols):
        cell = tbl.cell(0, c)
        cell.fill.solid()
        cell.fill.fore_color.rgb = POLLEO_BLUE
        cell.text = name
        for para in cell.text_frame.paragraphs:
            para.alignment = PP_ALIGN.LEFT if c <= 2 else PP_ALIGN.CENTER
            for r in para.runs:
                r.font.size = Pt(12)
                r.font.bold = True
                r.font.color.rgb = WHITE
                r.font.name = "Calibri"

    # Body — alt row banding on dark
    for ri, row in df.iterrows():
        cells = [
            str(row["SKU"]),
            str(row["Name"]),
            str(row["Category"]),
            f"{int(row['Total units']):,}",
            f"€{int(row['Revenue €']):,}",
            f"€{int(row['RUC €']):,}",
        ]
        bg_color = ROW_DARK if ri % 2 == 0 else ROW_DARK2
        for c, val in enumerate(cells):
            cell = tbl.cell(ri + 1, c)
            cell.fill.solid()
            cell.fill.fore_color.rgb = bg_color
            cell.text = val
            for para in cell.text_frame.paragraphs:
                para.alignment = PP_ALIGN.LEFT if c <= 2 else PP_ALIGN.CENTER
                for r in para.runs:
                    r.font.size = Pt(10.5)
                    r.font.color.rgb = WHITE if c >= 3 else LIGHT_GRAY
                    r.font.bold = (c >= 3)  # bold the numerics
                    r.font.name = "Calibri"

    # Footer note
    foot = slide.shapes.add_textbox(Inches(0.4), sh - Inches(0.45),
                                      sw - Inches(0.8), Inches(0.3))
    p = foot.text_frame.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    r = p.add_run()
    r.text = "Source: Polleo Demand · VP/MP on-top commitments · sell prices and RUC from latest 8 weeks"
    r.font.size = Pt(9)
    r.font.italic = True
    r.font.color.rgb = LIGHT_GRAY
    r.font.name = "Calibri"

    out = io.BytesIO()
    prs.save(out)
    return out.getvalue()


def page_sop_meeting():
    st.title("S&OP meeting view")
    cy, cw = get_current_cw()
    st.caption(f"Executive summary — CW{cw}, {cy}")

    sc = load_sales_data()
    plan = load_demand_plan()

    # ---- Promotions one-slider (one-slide S&OP report) ----
    _render_promo_one_slider()

    # --- Top metrics ---
    sku_prices = load_sku_prices()
    total_rev = compute_plan_revenue(plan, sku_prices) if plan else 0

    # Accuracy
    acc_df = compute_accuracy_metrics(sc) if sc is not None and plan is not None else None
    fa_pct = acc_df["fa"].mean() * 100 if acc_df is not None and not acc_df.empty else 0
    bias_pct = (acc_df["bias"].sum() / max(acc_df["actual"].sum(), 1) * 100) if acc_df is not None and not acc_df.empty else 0

    # Exceptions
    exceptions = compute_exceptions(sc) if sc is not None else {"spikes": [], "zero_sales": []}
    n_exceptions = len(exceptions["spikes"]) + len(exceptions["zero_sales"])

    c1, c2, c3, c4 = st.columns(4)
    c1.metric("13-wk revenue", f"€{total_rev:,.0f}")
    c2.metric("Forecast accuracy", f"{fa_pct:.1f}%")
    c3.metric("Bias", f"{'+' if bias_pct >= 0 else ''}{bias_pct:.1f}%")
    c4.metric("Exceptions", f"{n_exceptions}")

    # --- Revenue by week chart ---
    if plan:
        st.subheader("Revenue projection")
        cws = plan["cws"]
        weekly_rev = [0.0] * len(cws)
        for r in plan["rows"]:
            price = sku_prices.get(r["SKU"], 0)
            for j in range(len(cws)):
                b = r["baseline"][j] if j < len(r["baseline"]) else 0
                f = r["factors"][j] if j < len(r["factors"]) else 1.0
                weekly_rev[j] += b * f * price

        fig = go.Figure()
        fig.add_trace(go.Bar(
            x=cws, y=[round(v) for v in weekly_rev],
            marker_color="#2F5496",
            text=[f"€{round(v):,}" for v in weekly_rev],
            textposition="outside", textfont=dict(size=9),
        ))
        fig.update_layout(height=280, margin=dict(l=50,r=20,t=20,b=30),
                          yaxis_title="Revenue €", plot_bgcolor="rgba(0,0,0,0)",
                          paper_bgcolor="rgba(0,0,0,0)")
        fig.update_xaxes(showgrid=False)
        fig.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)", tickformat=",")
        st.plotly_chart(fig, use_container_width=True)

    # --- Top risks ---
    st.subheader("Top risks")
    risks = []
    for s in exceptions["spikes"][:3]:
        risks.append({
            "Risk": f"Demand spike: {s['name']} ({s['sku']})",
            "Impact": f"{s['change']:+}% WoW ({s['prev']}→{s['curr']})",
            "Action": "Verify with KAM, check stock"
        })
    if len(exceptions["zero_sales"]) > 0:
        n_zero = len(exceptions["zero_sales"])
        risks.append({
            "Risk": f"{n_zero} SKUs with zero sales 3+ weeks",
            "Impact": "Forecasted units with no demand",
            "Action": "Review for delist or season end"
        })
    if acc_df is not None and not acc_df.empty:
        low_fa = acc_df.groupby("oznaka")["fa"].mean()
        for ozn, fa in low_fa.items():
            if fa < 0.4:
                risks.append({
                    "Risk": f"Low accuracy: {ozn} tier at {fa*100:.0f}%",
                    "Impact": "Stock risk from forecast errors",
                    "Action": "Review model fit, consider reclassification"
                })

    if risks:
        st.dataframe(pd.DataFrame(risks), use_container_width=True, hide_index=True)
    else:
        st.success("No major risks detected.")

    # --- Key decisions ---
    st.subheader("Key decisions")
    st.caption("Track decisions from this S&OP cycle.")

    decisions_file = DATA_DIR / "sop_decisions.json"
    if decisions_file.exists():
        with open(decisions_file) as f:
            decisions = json.load(f)
    else:
        decisions = []

    # Add new decision
    with st.expander("➕ Add decision"):
        dc1, dc2, dc3 = st.columns([3, 1, 1])
        new_dec = dc1.text_input("Decision", key="new_dec")
        new_owner = dc2.text_input("Owner", key="new_owner")
        new_status = dc3.selectbox("Status", ["Pending", "In progress", "Done", "Overdue"], key="new_status")
        if st.button("Add", key="add_dec") and new_dec:
            decisions.append({
                "decision": new_dec, "owner": new_owner,
                "status": new_status, "cw": f"CW{cw}",
            })
            with open(decisions_file, "w") as f:
                json.dump(decisions, f, indent=2)
            st.rerun()

    if decisions:
        dec_df = pd.DataFrame(decisions)
        st.dataframe(dec_df.rename(columns={
            "decision": "Decision", "owner": "Owner", "status": "Status", "cw": "Created"
        }), use_container_width=True, hide_index=True)


# ==================================================================
# SIDEBAR + ROUTING
# ==================================================================

def page_erp_promo():
    """ERP Promo Calendar import page."""
    st.header("📅 ERP Promo Calendar")
    st.caption("Import promotion data from Gath ERP to improve forecast accuracy.")

    erp_file = DATA_DIR / "erp_promo_calendar.csv"
    rabatne_file = DATA_DIR / "rabatne.xlsx"

    # Status
    if erp_file.exists():
        df = pd.read_csv(erp_file)
        plan_df = df[df['sku'].isin(
            pd.read_csv(DATA_DIR / "sku_plan_list.csv")['sku'].tolist()
        )] if (DATA_DIR / "sku_plan_list.csv").exists() else df
        st.success(f"✅ ERP promo calendar loaded: {len(df):,} promo-week rows, {df['sku'].nunique():,} SKUs")
        yr_wk = list(zip(df['year'], df['week']))
        if yr_wk:
            st.caption(f"Coverage: {min(yr_wk)[0]}-CW{min(yr_wk)[1]:02d} → {max(yr_wk)[0]}-CW{max(yr_wk)[1]:02d}")
            st.caption(f"Planning SKUs with promo data: {plan_df['sku'].nunique()}")
    else:
        st.warning("No ERP promo calendar found. Upload rabatne.xlsx from Gath to get started.")

    st.divider()

    # Upload
    st.subheader("Import from Gath ERP")
    st.markdown("""
    **How to get the data:**
    1. Open the Gath ERP query tool
    2. Run the promotions query (Rabatne)
    3. Select all results → Ctrl+C
    4. Paste into Excel → Save as `rabatne.xlsx`
    5. Upload here
    """)

    uploaded = st.file_uploader("Upload rabatne.xlsx", type=["xlsx"], key="erp_upload")

    if uploaded:
        # Save to data dir
        with open(rabatne_file, "wb") as f:
            f.write(uploaded.read())
        st.info(f"Saved {uploaded.name} ({uploaded.size:,} bytes)")

        # Run build_erp_promo.py
        if st.button("🔄 Process promo data", use_container_width=True, key="btn_build_erp"):
            with st.spinner("Processing ERP promo data..."):
                script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "build_erp_promo.py")
                if not os.path.exists(script):
                    st.error("build_erp_promo.py not found in project directory.")
                    return
                result = subprocess.run(
                    ["python", script, str(rabatne_file), str(erp_file)],
                    capture_output=True, text=True
                )
                if result.returncode == 0:
                    st.success("✅ ERP promo calendar built successfully!")
                    st.text(result.stdout)
                    st.rerun()
                else:
                    st.error("Failed to process promo data.")
                    st.text(result.stderr)

    st.divider()

    # Recalculate uplift
    st.subheader("Recalculate Uplift")
    st.caption("Rebuild sku_uplift.csv and cat_uplift.csv using ERP-confirmed promo weeks.")

    if not erp_file.exists():
        st.info("Import ERP promo data first.")
    else:
        if st.button("🔄 Recalculate uplift from ERP data", use_container_width=True, key="btn_recalc"):
            with st.spinner("Recalculating uplift..."):
                script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recalc_uplift_erp.py")
                if not os.path.exists(script):
                    st.error("recalc_uplift_erp.py not found.")
                    return
                # Backup old uplift files
                for fn in ["sku_uplift.csv", "cat_uplift.csv"]:
                    src = DATA_DIR / fn
                    bak = DATA_DIR / fn.replace(".csv", "_old.csv")
                    if src.exists() and not bak.exists():
                        import shutil
                        shutil.copy(str(src), str(bak))
                result = subprocess.run(
                    ["python", script],
                    capture_output=True, text=True, cwd=str(DATA_DIR.parent)
                )
                if result.returncode == 0:
                    st.success("✅ Uplift recalculated!")
                    st.text(result.stdout)
                else:
                    st.error("Failed.")
                    st.text(result.stderr)

    # Show current promo data preview
    if erp_file.exists():
        st.divider()
        st.subheader("Promo Calendar Preview")
        df = pd.read_csv(erp_file)
        if (DATA_DIR / "sku_plan_list.csv").exists():
            plan_skus = pd.read_csv(DATA_DIR / "sku_plan_list.csv")['sku'].tolist()
            df = df[df['sku'].isin(plan_skus)]
        cy, cw = get_current_cw()
        # Show upcoming promos
        upcoming = df[(df['year'] > cy) | ((df['year'] == cy) & (df['week'] >= cw))]
        if len(upcoming) > 0:
            st.markdown(f"**Upcoming promos ({len(upcoming)} SKU-weeks):**")
            st.dataframe(upcoming.sort_values(['year', 'week', 'sku']).head(50), use_container_width=True)
        else:
            st.info("No upcoming promos in the calendar. Export fresh data from Gath to see future promos.")


# ==================================================================
# ==================================================================
#                     📦  SUPPLY  MODULE   v0.1
# ==================================================================
# Shares DATA_DIR with Demand. Reads forecast_for_supply.csv (written
# by write_forecast_for_supply() on consensus save or download).
# Reads stock.csv, incoming_supply.csv, supply_master.csv (user uploads).
# Writes orders_draft.json (CM order entries).
# ==================================================================

ORDERS_FILE = DATA_DIR / "orders_draft.json"

# Display-only remap for supplier names. Keys are the raw strings as they
# appear in supply_master.csv; values are what the UI shows. The CSV is
# untouched so upstream imports stay consistent.
SUPPLIER_DISPLAY_REMAP = {
    "03599 ZOE FASHION D.O.O.": "09244 ABC NUTRITIONAL LTD",
}


def remap_supplier(name):
    if name is None:
        return name
    return SUPPLIER_DISPLAY_REMAP.get(str(name).strip(), name)


# Safety-stock defaults (override on Supply → Settings page)
SUP_DEFAULTS = {
    "fa": 0.70,              # forecast accuracy
    "z": 1.65,               # service level factor (95%)
    "target_coverage": 8,    # weeks of stock to top up to
    "horizon": 15,           # weeks to project forward
}


# ------- Safety stock maths -------

def sup_safety_stock(avg_demand, lt_weeks, fa, z):
    """SS = Z × avg_demand × (1 − FA) × √LT  (units)."""
    if avg_demand <= 0 or lt_weeks <= 0:
        return 0.0
    return z * avg_demand * (1.0 - fa) * np.sqrt(lt_weeks)


def sup_reorder_point(avg_demand, lt_weeks, ss):
    return avg_demand * lt_weeks + ss


def sup_suggested_qty(on_hand, incoming_within_lt, avg_demand,
                      target_cov_weeks, moq):
    target = target_cov_weeks * avg_demand
    gap = target - on_hand - incoming_within_lt
    if gap <= 0:
        return 0
    return max(int(round(gap)), int(moq or 0))


# ------- Data loaders -------

@st.cache_data(ttl=60)
def sup_load_stock():
    p = DATA_DIR / "stock.csv"
    if not p.exists():
        return None
    df = pd.read_csv(p)
    df.columns = [str(c).strip().lower() for c in df.columns]
    return df


STORE_STOCK_FILES = [
    ("HR", "stock_stores.csv"),    # kept as-is for backward compatibility
    ("AT", "stock_stores_at.csv"),
    ("SLO", "stock_stores_slo.csv"),
]


@st.cache_data(ttl=60)
def sup_load_stock_stores():
    """Stores stock across HR / AT / SLO — optional. Used by Stock projection
    for full-company inventory € / units. Returns SKU-level sum across all
    countries / stores. Handles BOTH file formats transparently:
      - legacy 2-col: `sku, on_hand`  (one row per SKU, already aggregated)
      - long 4-col:   `sku, store_code, store_name, on_hand`  (per-store)
    """
    frames = []
    for _, fname in STORE_STOCK_FILES:
        p = DATA_DIR / fname
        if p.exists():
            df = pd.read_csv(p)
            df.columns = [str(c).strip().lower() for c in df.columns]
            # Long format → keep sku+on_hand, sum rolls up across stores
            frames.append(df[["sku", "on_hand"]])
    if not frames:
        return None
    combined = pd.concat(frames, ignore_index=True)
    return combined.groupby("sku", as_index=False)["on_hand"].sum()


@st.cache_data(ttl=60)
def sup_load_stock_stores_long(country=None):
    """Per-store stock rows. Returns DataFrame with columns
    `sku, country, store_code, store_name, on_hand`, or None if no country
    file is in long format. If `country` is given (e.g. "HR"), filters to
    that country only. Files in legacy 2-col format are skipped — they
    have no per-store information."""
    frames = []
    for c, fname in STORE_STOCK_FILES:
        if country is not None and c != country:
            continue
        p = DATA_DIR / fname
        if not p.exists():
            continue
        df = pd.read_csv(p)
        df.columns = [str(col).strip().lower() for col in df.columns]
        if "store_code" not in df.columns:
            continue
        if "store_name" not in df.columns:
            df["store_name"] = df["store_code"].astype(str)
        df = df[["sku", "store_code", "store_name", "on_hand"]].copy()
        df["country"] = c
        frames.append(df)
    if not frames:
        return None
    return pd.concat(frames, ignore_index=True)[
        ["sku", "country", "store_code", "store_name", "on_hand"]
    ]


@st.cache_data(ttl=60)
def sup_load_forecast():
    p = DATA_DIR / "forecast_for_supply.csv"
    if not p.exists():
        return None
    df = pd.read_csv(p)
    df.columns = [str(c).strip().lower() for c in df.columns]
    return df


@st.cache_data(ttl=60)
def sup_load_incoming():
    p = DATA_DIR / "incoming_supply.csv"
    if not p.exists():
        return None
    df = pd.read_csv(p)
    # Tolerate UPPERCASE / MixedCase headers (some ERP exports come that way).
    df.columns = [str(c).strip().lower() for c in df.columns]
    return df


@st.cache_data(ttl=60)
def sup_load_master():
    """Merge sku_plan_list.csv (name/cat/tier from Demand side) with
    supply_master.csv (supplier/lead_time/moq from Supply side) into a
    single frame. Missing MOQ / LT get sensible defaults.
    """
    plan_path = DATA_DIR / "sku_plan_list.csv"
    sup_path = DATA_DIR / "supply_master.csv"
    if not plan_path.exists():
        return None

    plan = pd.read_csv(plan_path)
    plan.columns = [str(c).strip().lower() for c in plan.columns]
    # normalise demand-side columns
    cols = {"cat": "category", "oznaka": "tier"}
    plan = plan.rename(columns={k: v for k, v in cols.items() if k in plan.columns})
    keep = [c for c in ["sku", "name", "category", "tier"] if c in plan.columns]
    master = plan[keep].copy()

    if sup_path.exists():
        sup = pd.read_csv(sup_path)
        sup.columns = [str(c).strip().lower() for c in sup.columns]
        for c in ("supplier", "lead_time_weeks", "moq"):
            if c not in sup.columns:
                sup[c] = np.nan
        master = master.merge(
            sup[["sku", "supplier", "lead_time_weeks", "moq"]],
            on="sku", how="left",
        )
    else:
        master["supplier"] = ""
        master["lead_time_weeks"] = np.nan
        master["moq"] = np.nan

    master["lead_time_weeks"] = master["lead_time_weeks"].fillna(8).astype(float)
    master["moq"] = master["moq"].fillna(0).astype(float)
    master["supplier"] = master["supplier"].fillna("").map(remap_supplier)
    master["category"] = master.get("category", "").fillna("")
    master["tier"] = master.get("tier", "").fillna("")
    return master


def sup_load_orders():
    if ORDERS_FILE.exists():
        return json.loads(ORDERS_FILE.read_text())
    return {}


def sup_save_orders(orders):
    ORDERS_FILE.write_text(json.dumps(orders, indent=2))


def sup_params():
    return st.session_state.get("sup_params", SUP_DEFAULTS)


# ------- Coverage engine: forward-walk stock -------

def sup_build_coverage(stock, forecast, incoming, master, params, current_yw):
    """Walk stock forward HORIZON weeks. Returns (long_rows_df, summary_df, weeks).

    Closing stock floored at zero (unmet demand not carried forward).
    """
    cy, cw = current_yw
    weeks = []
    y, w = cy, cw
    for _ in range(params["horizon"]):
        weeks.append((y, w))
        w += 1
        if w > 52:
            w = 1
            y += 1

    on_hand = dict(zip(stock["sku"], stock["on_hand"])) if stock is not None else {}

    def _pivot(df, val_col):
        if df is None or len(df) == 0:
            return {}
        d = df.copy()
        d["yw"] = d["year"] * 100 + d["week"]
        return {(r.sku, r.yw): getattr(r, val_col) for r in d.itertuples()}

    fc_lookup = _pivot(forecast, "demand")
    in_lookup = _pivot(incoming, "qty")

    long_rows = []
    summary = []

    for _, m in master.iterrows():
        sku = m["sku"]
        lt = float(m["lead_time_weeks"])
        moq = float(m["moq"] or 0)

        opening = float(on_hand.get(sku, 0))
        stock_now = opening
        demands = []
        closing_at_lt = None
        closing_at_lt1 = None
        incoming_in_lt = 0.0

        for i, (yy, ww) in enumerate(weeks):
            yw = yy * 100 + ww
            d = float(fc_lookup.get((sku, yw), 0))
            inc = float(in_lookup.get((sku, yw), 0))
            closing = max(0.0, opening + inc - d)
            cov = (closing / d) if d > 0 else np.nan

            long_rows.append({
                "sku": sku, "year": yy, "week": ww, "yw": yw,
                "stock": opening, "demand": d, "incoming": inc,
                "closing": closing, "coverage_wk": cov,
            })
            demands.append(d)
            if i < int(lt):
                incoming_in_lt += inc
            if i == int(lt):
                closing_at_lt = closing
            if i == int(lt) + 1:
                closing_at_lt1 = closing
            opening = closing

        avg_d = float(np.mean(demands)) if demands else 0.0
        ss = sup_safety_stock(avg_d, lt, params["fa"], params["z"])
        rop = sup_reorder_point(avg_d, lt, ss)

        if closing_at_lt is not None and closing_at_lt <= ss:
            flag = "order_now"
        elif closing_at_lt1 is not None and closing_at_lt1 <= ss:
            flag = "order_next_week"
        else:
            flag = "ok"

        # Suggested quantity aligned with the flag: only recommend ordering when
        # the lookahead says action is needed. Size the order to bring the
        # projected lead-time-boundary stock up to target_coverage × avg_demand,
        # then round up to MOQ.
        if flag == "ok":
            sugg = 0
        else:
            target_units = params["target_coverage"] * avg_d
            projected_at_lt = closing_at_lt if closing_at_lt is not None else 0
            gap = target_units - projected_at_lt
            if gap <= 0:
                sugg = int(moq or 0)
            else:
                sugg = max(int(round(gap)), int(moq or 0))

        summary.append({
            "sku": sku,
            "name": m.get("name", ""),
            "supplier": m.get("supplier", ""),
            "category": m.get("category", ""),
            "tier": m.get("tier", ""),
            "lt_weeks": lt,
            "moq": int(moq),
            "on_hand": int(round(stock_now)),
            "avg_weekly_demand": round(avg_d, 1),
            "safety_stock": int(round(ss)),
            "rop": int(round(rop)),
            "coverage_wk_now": round((stock_now / avg_d), 1) if avg_d > 0 else np.nan,
            "closing_at_lt": int(round(closing_at_lt or 0)),
            "flag": flag,
            "suggested_qty": int(sugg),
        })

    return pd.DataFrame(long_rows), pd.DataFrame(summary), weeks


# ------- Scenario planner engine (used by Supply → Scenario planner) -------
# Pure functions, no Streamlit deps. Drive the per-PO cancel/postpone decisions
# and the company-wide € projection used by the scenario page.

def scen_week_step(y, w, n=1):
    """Step (y, w) forward by n weeks. Naive 52-week year (matches CW logic
    used elsewhere in the app)."""
    w_new = w + n
    while w_new > 52:
        w_new -= 52
        y += 1
    while w_new < 1:
        w_new += 52
        y -= 1
    return (y, w_new)


def scen_week_series(start_y, start_w, n_weeks):
    out = []
    y, w = start_y, start_w
    for _ in range(n_weeks):
        out.append((y, w))
        y, w = scen_week_step(y, w, 1)
    return out


def scen_real_weeks_cover(stock_now, demand_series):
    """Walk-forward weeks of cover. Returns fractional weeks. Trailing
    extrapolation uses the series average so very-over-ordered SKUs read
    as the large numbers needed to trip the cancel threshold."""
    remaining = float(stock_now)
    weeks = 0.0
    for d in demand_series:
        d = float(d)
        if d <= 0:
            weeks += 1
            continue
        if remaining >= d:
            remaining -= d
            weeks += 1
        else:
            weeks += remaining / d
            return weeks
    avg = sum(demand_series) / max(len(demand_series), 1)
    if avg > 0:
        return weeks + remaining / avg
    return weeks + 999  # no demand at all → effectively infinite


def scen_classify(real_cover_now, real_post_delivery_cover,
                  cancel_threshold, postpone_trigger=4):
    """3-state classifier driven by cover thresholds.

    Both CANCEL and POSTPONE gate on CURRENT cover (not post-delivery cover).
    This protects against cancelling a PO for a SKU that's already thin —
    the SKU must already have enough buffer to survive without that PO.
    `real_post_delivery_cover` is kept as an informational return value
    used elsewhere in the page (table column) but no longer drives the
    classification.
    """
    if cancel_threshold is not None and real_cover_now >= cancel_threshold:
        return "CANCEL"
    if real_cover_now >= postpone_trigger:
        return "POSTPONE"
    return "PRODUCE"


def scen_sanity_check(per_po_df, action_col, all_inc_m, fc_lookup, wh_map,
                      walk_weeks, postpone_delay):
    """If a SKU has 2+ POSTPONE POs in window, simulate stacked postpones.
    Un-postpone latest-week PO until projected stock no longer dips below
    1 week of real cover at any week in the horizon. Returns the adjusted
    action column and a list of (sku, po_week) tuples that got flipped."""
    adjusted = per_po_df[action_col].copy()
    flips = []
    for sku, g in per_po_df.groupby("sku"):
        idxs = g.index.tolist()
        actions = {(sku, int(per_po_df.loc[i, "po_year"]),
                    int(per_po_df.loc[i, "po_week"])): adjusted.loc[i]
                   for i in idxs}
        postpones = [i for i in idxs if adjusted.loc[i] == "POSTPONE"]
        if len(postpones) < 2:
            continue
        sku_inc = all_inc_m[all_inc_m["sku"] == sku]
        while True:
            # Build adjusted inflow map for this SKU
            inflow = {}
            for _, p in sku_inc.iterrows():
                py, pw = int(p["year"]), int(p["week"])
                pq = float(p["qty"])
                key = (sku, py, pw)
                act = actions.get(key)
                if act == "CANCEL":
                    continue
                if act == "POSTPONE":
                    ny, nw = scen_week_step(py, pw, postpone_delay)
                    inflow[ny * 100 + nw] = inflow.get(ny * 100 + nw, 0) + pq
                else:
                    inflow[py * 100 + pw] = inflow.get(py * 100 + pw, 0) + pq
            # Walk WH stock
            s = float(wh_map.get(sku, 0))
            violation = False
            for (y, w) in walk_weeks:
                k = y * 100 + w
                d = fc_lookup.get((sku, k), 0.0)
                s = max(0.0, s + inflow.get(k, 0) - d)
                if d > 0 and s < d:
                    violation = True
                    break
            if not violation:
                break
            # flip latest-week active POSTPONE → PRODUCE
            active_postpones = sorted(
                [i for i in idxs
                 if actions.get((sku, int(per_po_df.loc[i, "po_year"]),
                                 int(per_po_df.loc[i, "po_week"]))) == "POSTPONE"],
                key=lambda i: (per_po_df.loc[i, "po_year"], per_po_df.loc[i, "po_week"]),
                reverse=True,
            )
            if not active_postpones:
                break
            flip_i = active_postpones[0]
            key = (sku, int(per_po_df.loc[flip_i, "po_year"]),
                   int(per_po_df.loc[flip_i, "po_week"]))
            actions[key] = "PRODUCE"
            adjusted.loc[flip_i] = "PRODUCE"
            flips.append(key)
    return adjusted, flips


def scen_company_eur_projection(all_inc_m, scenario_actions, cost_map,
                                fc_lookup, all_skus_for_outflow,
                                walk_weeks, start_eur, postpone_delay,
                                postpone_targets=None):
    """Walk company-wide stock € forward. Inflows = adjusted POs × cost_price,
    outflows = forecast × cost_price summed across all SKUs. CW20 (first
    week) is anchored to start_eur; movement begins at week 2 of walk_weeks.

    `postpone_targets` (optional): {(sku, py, pw): (new_y, new_w)} — per-PO
    explicit new delivery week. When set for a POSTPONE action, overrides the
    default `postpone_delay` step. Used by manual scenario overrides on the
    Scenario planner page."""
    postpone_targets = postpone_targets or {}
    inflow_by_yw = {}
    for _, p in all_inc_m.iterrows():
        sku, y, w, q = p["sku"], int(p["year"]), int(p["week"]), float(p["qty"])
        cost = float(cost_map.get(sku, 0.0))
        key = (sku, y, w)
        if key in scenario_actions:
            act = scenario_actions[key]
            if act == "CANCEL":
                continue
            if act == "POSTPONE":
                if key in postpone_targets:
                    ny, nw = postpone_targets[key]
                else:
                    ny, nw = scen_week_step(y, w, postpone_delay)
                inflow_by_yw[ny * 100 + nw] = inflow_by_yw.get(ny * 100 + nw, 0.0) + q * cost
                continue
        inflow_by_yw[y * 100 + w] = inflow_by_yw.get(y * 100 + w, 0.0) + q * cost

    s = float(start_eur)
    out = {}
    for i, (y, w) in enumerate(walk_weeks):
        if i == 0:
            out[(y, w)] = s   # anchor
            continue
        key = y * 100 + w
        inflow = inflow_by_yw.get(key, 0.0)
        outflow = sum(fc_lookup.get((sku, key), 0.0) * cost_map.get(sku, 0.0)
                      for sku in all_skus_for_outflow)
        s = max(0.0, s + inflow - outflow)
        out[(y, w)] = s
    return out


def _sup_data_guard():
    """Load all four supply inputs. Return (stock, forecast, incoming, master)
    or None if anything critical is missing — also writes a user-facing warning."""
    stock = sup_load_stock()
    forecast = sup_load_forecast()
    incoming = sup_load_incoming()
    master = sup_load_master()

    missing = []
    if stock is None: missing.append("stock.csv")
    if forecast is None: missing.append("forecast_for_supply.csv")
    if master is None: missing.append("sku_plan_list.csv")

    if missing:
        st.warning("Missing files: " + ", ".join(missing))
        st.caption("Go to **Supply → Upload data** to upload them, or run the "
                   "Demand → Download page to generate `forecast_for_supply.csv`.")
        return None

    # Non-blocking staleness check: forecast_for_supply.csv older than 48h
    # almost always means the user forgot to re-run the Demand pipeline.
    # Don't block — the data is still valid, just probably outdated.
    fsp = DATA_DIR / "forecast_for_supply.csv"
    if fsp.exists():
        age_h = (datetime.now().timestamp() - fsp.stat().st_mtime) / 3600
        if age_h > 48:
            st.warning(
                "⚠️ Supply podatci su stariji od 48h — pokreni novi forecast ili "
                f"spremi consensus plan. (Last refresh: "
                f"{datetime.fromtimestamp(fsp.stat().st_mtime):%Y-%m-%d %H:%M}, "
                f"{age_h:.0f}h ago)"
            )

    if incoming is None:
        # Treat as empty — not blocking
        incoming = pd.DataFrame(columns=["sku", "year", "week", "qty"])
    return stock, forecast, incoming, master


# ==================================================================
# SUPPLY PAGES
# ==================================================================

def page_supply_dashboard():
    st.title("📦 Supply dashboard")
    st.caption(f"CW{get_current_cw()[1]} · {get_current_cw()[0]}")

    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    params = sup_params()

    _, summary, _ = sup_build_coverage(stock, forecast, incoming, master,
                                       params, get_current_cw())

    c = st.columns(4)
    c[0].metric("SKUs in scope", len(summary))
    c[1].metric("🔴 Order now", int((summary["flag"] == "order_now").sum()))
    c[2].metric("🟡 Next week", int((summary["flag"] == "order_next_week").sum()))
    avg_cov = summary["coverage_wk_now"].replace([np.inf, -np.inf], np.nan).dropna().mean()
    c[3].metric("Avg coverage wk", f"{avg_cov:.1f}" if not np.isnan(avg_cov) else "—")

    st.subheader("By category")
    by_cat = summary.groupby("category").agg(
        skus=("sku", "count"),
        order_now=("flag", lambda s: int((s == "order_now").sum())),
        order_next=("flag", lambda s: int((s == "order_next_week").sum())),
        avg_cov=("coverage_wk_now", "mean"),
    ).round(1).sort_values("order_now", ascending=False)
    st.dataframe(by_cat, use_container_width=True)

    st.subheader("By tier")
    by_tier = summary.groupby("tier").agg(
        skus=("sku", "count"),
        order_now=("flag", lambda s: int((s == "order_now").sum())),
        order_next=("flag", lambda s: int((s == "order_next_week").sum())),
        avg_cov=("coverage_wk_now", "mean"),
    ).round(1)
    st.dataframe(by_tier, use_container_width=True)


def page_supply_projection():
    st.title("📈 Stock projection")
    st.caption("Global stock roll-forward: opening − demand + incoming. "
               "Per-SKU detail lives on Coverage and Inventory health.")

    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    params = sup_params()
    cy, cw = get_current_cw()

    # Fold stores into opening stock — for the full-company CFO picture.
    # Coverage / alerts still use WH-only (stock.csv).
    stores = sup_load_stock_stores()
    uploaded_countries = [c for c, f in STORE_STOCK_FILES if (DATA_DIR / f).exists()]
    missing_countries = [c for c, f in STORE_STOCK_FILES if not (DATA_DIR / f).exists()]
    if stores is not None and len(stores):
        combined = pd.concat([stock, stores], ignore_index=True)
        stock = combined.groupby("sku", as_index=False)["on_hand"].sum()
        caption = f"📦 Includes store stock: **{', '.join(uploaded_countries)}** ({len(stores)} SKUs)"
        if missing_countries:
            caption += f" · ⚪ missing: {', '.join(missing_countries)}"
        st.caption(caption)
    else:
        st.caption("📦 WH stock only — upload store stock on Supply → Upload data for full picture.")

    costs_path = DATA_DIR / "sku_costs.csv"
    using_cost = costs_path.exists()
    cost_map = {}
    if using_cost:
        costs = pd.read_csv(costs_path)
        cost_map = dict(zip(costs["sku"], costs["cost_price"]))
    else:
        st.info("`sku_costs.csv` missing — value line will be 0. "
                "Upload via Supply → Cost prices.")

    # ---- Extend master + forecast to cover long-tail (non-forecasted) SKUs ----
    # Any SKU that has stock but no forecast gets a trailing-13w-avg demand proxy
    # so WH + CFO see the full picture (clothing/fitness/gadgets/low-sales tail).
    # This is run-rate, not a forecast — flat across the horizon.
    tiered_skus = set(master["sku"])
    stock_skus = set(stock["sku"])
    longtail_skus = stock_skus - tiered_skus

    sales = load_sales_data()
    if longtail_skus and sales is not None:
        cur_yw = cy * 100 + cw
        recent = sales[(sales["yw"] < cur_yw) & (sales["sku"].isin(longtail_skus))]
        if len(recent):
            yw_cut = sorted(recent["yw"].unique())[-TRAILING_WEEKS // 2:]  # last 13w
            recent13 = recent[recent["yw"].isin(yw_cut)]
            avg_weekly = recent13.groupby("sku")["qty_total"].sum() / max(len(yw_cut), 1)
        else:
            avg_weekly = pd.Series(dtype=float)

        # Build synthetic forecast rows (flat avg across horizon)
        horizon_weeks = []
        y, w = cy, cw
        for _ in range(params["horizon"]):
            w += 1
            if w > 52:
                w = 1
                y += 1
            horizon_weeks.append((y, w))

        synth_rows = []
        for sku in longtail_skus:
            d = float(avg_weekly.get(sku, 0.0))
            for (yy, ww) in horizon_weeks:
                synth_rows.append({"sku": sku, "year": yy, "week": ww, "demand": d})
        if synth_rows:
            forecast = pd.concat([forecast, pd.DataFrame(synth_rows)], ignore_index=True)

        # Extend master — flag these rows so the user can filter
        extra_master = pd.DataFrame({
            "sku": list(longtail_skus),
            "name": "",
            "category": "LONG_TAIL",
            "tier": "UNTIERED",
            "supplier": "",
            "lead_time_weeks": 8.0,
            "moq": 0.0,
        })
        master = pd.concat([master, extra_master], ignore_index=True)

    cats = sorted([c for c in master["category"].dropna().unique() if c])
    cat_sel = st.multiselect(
        "Category", cats, default=[], key="proj_cat",
        placeholder="All categories",
    )
    if cat_sel:
        allowed_skus = set(master[master["category"].isin(cat_sel)]["sku"])
    else:
        allowed_skus = set(master["sku"])

    # ---- Per-KAM (and per-buyer) VP on-top exclusion ----
    # Subtract excluded customers' VP from the demand line, so the
    # projection reflects "what if customer X doesn't buy this round?".
    #
    # Two granularities supported:
    #   - KAM-level (always available): untick a KAM → all their VP rows drop.
    #   - Buyer-level (when vp_input_detail.csv has a populated `buyer`
    #     column from a multi-sheet upload): untick a specific buyer under
    #     an included KAM → only that buyer's rows drop. Buyer LIST comes
    #     from kam_cm_config.json so the UI is stable even before any
    #     per-buyer upload has happened.
    vp_detail_path = DATA_DIR / "vp_input_detail.csv"
    excluded_kams = set()                # KAMs fully excluded
    excluded_buyers = set()              # (kam, buyer) tuples to exclude (only when KAM is included)
    vp_detail = None
    if vp_detail_path.exists():
        try:
            vp_detail = pd.read_csv(vp_detail_path)
        except Exception:
            vp_detail = None

    # KAM -> [buyers] from config (display_name keyed, role=VP only)
    kam_buyers_map = {}
    cfg_path = DATA_DIR / "kam_cm_config.json"
    if cfg_path.exists():
        try:
            with open(cfg_path, encoding="utf-8") as _f:
                _cfg = json.load(_f)
            for _k, _v in (_cfg.get("kam_cm_config", {}) or {}).items():
                if isinstance(_v, dict) and _v.get("role") == "VP":
                    _dn = _v.get("display_name") or _k
                    kam_buyers_map[str(_dn)] = list(_v.get("buyers") or [])
        except Exception:
            kam_buyers_map = {}

    # Buyer column populated? Backward-compat: pre-multi-sheet uploads
    # lack this column entirely.
    has_buyer_col = (
        vp_detail is not None
        and "buyer" in vp_detail.columns
        and vp_detail["buyer"].astype(str).str.strip().ne("").any()
    )

    if vp_detail is not None and "kam" in vp_detail.columns:
        vp_kams = sorted(vp_detail["kam"].dropna().astype(str).unique().tolist())
        if vp_kams:
            with st.expander(f"Exclude VP on-top per customer ({len(vp_kams)} KAMs)", expanded=False):
                st.caption(
                    "Untick a KAM to drop all their VP on-top from the demand line. "
                    "Expand a KAM to untick individual buyers."
                    + ("" if has_buyer_col else
                       " · *Buyer-level data not present in `vp_input_detail.csv` "
                       "yet — buyer checkboxes will activate after the next "
                       "multi-sheet KAM upload (one sheet per buyer).*")
                )
                for kam in vp_kams:
                    kam_included = st.checkbox(
                        f"**{kam}**", value=True, key=f"proj_vpkam_{kam}"
                    )
                    if not kam_included:
                        excluded_kams.add(kam)

                    buyers_for_kam = kam_buyers_map.get(kam, [])
                    # Which buyers actually appear in the detail file for this KAM
                    detail_buyers = set()
                    if has_buyer_col:
                        rows_for_kam = vp_detail[vp_detail["kam"].astype(str) == kam]
                        detail_buyers = {b for b in rows_for_kam["buyer"].astype(str).str.strip().unique() if b}

                    if buyers_for_kam:
                        with st.expander(f"&nbsp;&nbsp;Buyers ({len(buyers_for_kam)})", expanded=False):
                            if not has_buyer_col:
                                st.caption("ℹ️ Buyer-level detail unavailable in current "
                                           "`vp_input_detail.csv`. Per-buyer subtraction will "
                                           "activate after a multi-sheet KAM template upload.")
                            elif not detail_buyers:
                                st.caption(f"ℹ️ No per-buyer rows for **{kam}** in the current "
                                           "`vp_input_detail.csv`. KAM's quantities come from "
                                           "a single aggregated sheet — toggle the KAM checkbox "
                                           "above to subtract all, or upload a multi-sheet template.")
                            bcols = st.columns(min(len(buyers_for_kam), 4))
                            for i, b in enumerate(buyers_for_kam):
                                buyer_in_data = b in detail_buyers
                                disabled = (not kam_included) or (not buyer_in_data)
                                help_txt = None
                                if not kam_included:
                                    help_txt = f"{kam} is fully excluded above."
                                elif not buyer_in_data:
                                    help_txt = ("No rows for this buyer in vp_input_detail.csv. "
                                                "Upload a multi-sheet template (one sheet per buyer) "
                                                "to enable per-buyer subtraction.")
                                b_included = bcols[i % len(bcols)].checkbox(
                                    b, value=True, key=f"proj_vpbuyer_{kam}_{b}",
                                    disabled=disabled, help=help_txt,
                                )
                                if kam_included and buyer_in_data and not b_included:
                                    excluded_buyers.add((kam, b))

    # Build the forecast frame the coverage walker will use. Subtract:
    #   - All rows for KAMs in excluded_kams (KAM-level exclusion)
    #   - All rows where (kam, buyer) is in excluded_buyers (per-buyer)
    # The two sets are disjoint by construction: excluded_buyers only
    # gets populated when the KAM is INCLUDED.
    forecast_eff = forecast
    if (excluded_kams or excluded_buyers) and vp_detail is not None:
        cw_to_yw = {}
        y_seek, w_seek = cy, cw
        for _ in range(params["horizon"]):
            w_seek += 1
            if w_seek > 52:
                w_seek = 1
                y_seek += 1
            cw_to_yw[f"CW{w_seek}"] = y_seek * 100 + w_seek

        excl_mask = pd.Series(False, index=vp_detail.index)
        if excluded_kams:
            excl_mask |= vp_detail["kam"].astype(str).isin(excluded_kams)
        if excluded_buyers and has_buyer_col:
            kam_str = vp_detail["kam"].astype(str)
            buyer_str = vp_detail["buyer"].astype(str).str.strip()
            for (k_ex, b_ex) in excluded_buyers:
                excl_mask |= (kam_str == k_ex) & (buyer_str == b_ex)

        excl = vp_detail[excl_mask].copy()
        if not excl.empty:
            cw_cols = [c for c in excl.columns if str(c).startswith("CW") and c in cw_to_yw]
            if cw_cols:
                melted = excl.melt(
                    id_vars=["sku"], value_vars=cw_cols,
                    var_name="cw_label", value_name="vp_excl",
                )
                melted["vp_excl"] = pd.to_numeric(melted["vp_excl"], errors="coerce").fillna(0)
                melted = melted[melted["vp_excl"] > 0]
                if not melted.empty:
                    melted["yw"] = melted["cw_label"].map(cw_to_yw)
                    subtract = melted.groupby(["sku", "yw"], as_index=False)["vp_excl"].sum()

                    forecast_eff = forecast.copy()
                    forecast_eff["yw"] = forecast_eff["year"] * 100 + forecast_eff["week"]
                    forecast_eff = forecast_eff.merge(subtract, on=["sku", "yw"], how="left")
                    forecast_eff["vp_excl"] = forecast_eff["vp_excl"].fillna(0)
                    forecast_eff["demand"] = (forecast_eff["demand"] - forecast_eff["vp_excl"]).clip(lower=0)
                    forecast_eff = forecast_eff.drop(columns=["yw", "vp_excl"])

                    total_subtracted = int(subtract["vp_excl"].sum())
                    bits = []
                    if excluded_kams:
                        bits.append("KAM(s): " + ", ".join(f"{k} (all buyers)"
                                                            for k in sorted(excluded_kams)))
                    if excluded_buyers:
                        bits.append("buyer(s): " + ", ".join(
                            f"{k}→{b}" for (k, b) in sorted(excluded_buyers)
                        ))
                    st.caption(
                        f"↘️ Subtracted **{total_subtracted:,} units** of VP on-top from "
                        + "; ".join(bits)
                        + "."
                    )

    long_rows, _, _ = sup_build_coverage(
        stock, forecast_eff, incoming, master, params, (cy, cw)
    )
    long = long_rows[long_rows["sku"].isin(allowed_skus)].copy()
    long["is_longtail"] = long["sku"].isin(longtail_skus)
    long["value_close"] = long["sku"].map(cost_map).fillna(0.0) * long["closing"]

    agg = long.groupby(["year", "week", "yw"], as_index=False).agg(
        opening=("stock", "sum"),
        demand=("demand", "sum"),
        incoming=("incoming", "sum"),
        closing=("closing", "sum"),
        closing_value=("value_close", "sum"),
    ).sort_values("yw").reset_index(drop=True)

    stock_f = stock[stock["sku"].isin(allowed_skus)]
    current_units = float(stock_f["on_hand"].sum())
    current_value = float(
        (stock_f["on_hand"] * stock_f["sku"].map(cost_map).fillna(0.0)).sum()
    )
    total_demand = float(agg["demand"].sum())
    total_incoming = float(agg["incoming"].sum())

    c = st.columns(4)
    c[0].metric("Current stock (units)", f"{int(current_units):,}")
    c[1].metric("Current stock (€)", f"{current_value:,.0f}")
    c[2].metric(f"Demand next {len(agg)}w", f"{int(total_demand):,}")
    c[3].metric(f"Incoming next {len(agg)}w", f"{int(total_incoming):,}")

    # Forecasted vs long-tail split (current stock value)
    if longtail_skus and using_cost:
        lt_stock = stock_f[stock_f["sku"].isin(longtail_skus)]
        lt_value = float(
            (lt_stock["on_hand"] * lt_stock["sku"].map(cost_map).fillna(0.0)).sum()
        )
        fc_value = current_value - lt_value
        total = max(current_value, 1)
        st.caption(
            f"**Stock € split** — forecasted: €{fc_value:,.0f} "
            f"({fc_value / total * 100:.0f}%) · "
            f"long-tail (run-rate proxy): €{lt_value:,.0f} "
            f"({lt_value / total * 100:.0f}%)"
        )

    x_labels = [f"CW{int(w)}" for w in agg["week"]]

    fig = go.Figure()
    fig.add_trace(go.Bar(
        x=x_labels,
        y=[-int(v) for v in agg["demand"]],
        name="Demand",
        marker_color="rgba(200, 80, 80, 0.55)",
        text=[f"-{int(v):,}" if v > 0 else "" for v in agg["demand"]],
        textposition="outside", textfont=dict(size=9),
    ))
    fig.add_trace(go.Bar(
        x=x_labels,
        y=[int(v) for v in agg["incoming"]],
        name="Incoming",
        marker_color="rgba(80, 160, 100, 0.55)",
        text=[f"+{int(v):,}" if v > 0 else "" for v in agg["incoming"]],
        textposition="outside", textfont=dict(size=9),
    ))
    fig.add_trace(go.Scatter(
        x=x_labels,
        y=[int(v) for v in agg["closing"]],
        name="Closing stock",
        mode="lines+markers+text",
        line=dict(color="#2F5496", width=2.5),
        marker=dict(size=7),
        text=[f"{int(v):,}" for v in agg["closing"]],
        textposition="top center", textfont=dict(size=9),
    ))
    fig.update_layout(
        barmode="relative", height=460,
        yaxis=dict(title="Units", tickformat=","),
        margin=dict(l=60, r=20, t=30, b=40),
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
        legend=dict(orientation="h", y=1.05),
    )
    fig.update_xaxes(showgrid=False)
    fig.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)",
                     zeroline=True, zerolinecolor="#999")
    st.plotly_chart(fig, use_container_width=True)

    if using_cost:
        st.subheader("Stock value roll-forward")
        fig_val = go.Figure()
        fig_val.add_trace(go.Scatter(
            x=x_labels,
            y=[round(v) for v in agg["closing_value"]],
            mode="lines+markers+text",
            line=dict(color="#B8860B", width=2.5),
            marker=dict(size=7),
            text=[f"€{int(v):,}" for v in agg["closing_value"]],
            textposition="top center", textfont=dict(size=9),
            name="Closing stock value (€)",
        ))
        fig_val.update_layout(
            height=300,
            yaxis=dict(title="€", tickformat=","),
            margin=dict(l=60, r=20, t=30, b=40),
            hovermode="x unified",
            plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
            legend=dict(orientation="h", y=1.05),
        )
        fig_val.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)")
        st.plotly_chart(fig_val, use_container_width=True)

    with st.expander("Weekly detail"):
        disp = agg.copy()
        disp["week"] = disp["week"].map(lambda w: f"CW{int(w)}")
        disp = disp[["week", "opening", "demand", "incoming", "closing", "closing_value"]]
        disp.columns = ["Week", "Opening", "Demand", "Incoming", "Closing", "Closing value €"]
        st.dataframe(disp, use_container_width=True, hide_index=True)

    st.caption("💡 Later: pallets view once logistics data lands (cases/pallet × units).")


def page_supply_scenarios():
    """Scenario planner: cancel/postpone POs to flatten a stock peak.

    Both gates run on CURRENT cover (stock now ÷ forward demand):
      - postpone_trigger: cover_now ≥ N weeks → POSTPONE candidate
      - cancel_threshold: cover_now ≥ N weeks → CANCEL (None = off)
      - postpone_delay: weeks to push POSTPONE deliveries back
    Cancel threshold is constrained ≥ postpone_trigger so cancel always wins
    on overlapping SKUs. Multi-PO stacking sanity check un-postpones latest
    PO if combined delay would cause a stockout in the horizon.
    """
    st.title("🎯 Scenario planner")
    st.caption("Stress-test the stock projection by cancelling or postponing "
               "incoming POs. Built for stock-peak events — pick a supplier, "
               "tune the policy, send the supplier-ready list.")

    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    cy, cw = get_current_cw()

    # ---- Build maps once ----
    wh_map = dict(zip(stock["sku"], stock["on_hand"]))
    stores = sup_load_stock_stores()
    stores_map = dict(zip(stores["sku"], stores["on_hand"])) if stores is not None else {}

    costs_path = DATA_DIR / "sku_costs.csv"
    if not costs_path.exists():
        st.warning("`sku_costs.csv` missing — value projection will be 0. "
                   "Upload via Supply → Cost prices.")
        cost_map = {}
    else:
        costs = pd.read_csv(costs_path)
        cost_map = dict(zip(costs["sku"], costs["cost_price"]))

    # Merge supplier onto incoming
    master_cols = ["sku", "supplier", "tier", "category"]
    if "name" in master.columns:
        master_cols.append("name")
    inc_m = incoming.merge(master[master_cols], on="sku", how="left")
    if "name" not in inc_m.columns:
        inc_m["name"] = ""
    inc_m["name"] = inc_m["name"].fillna("")
    inc_m["supplier"] = inc_m["supplier"].fillna("(unknown)")

    # SKU universe for outflow side of cash projection
    all_skus_for_outflow = set(stock["sku"]) | set(stores_map.keys()) | set(forecast["sku"])

    # Forecast lookup
    fc_local = forecast.copy()
    fc_local["yw"] = fc_local["year"] * 100 + fc_local["week"]
    fc_lookup = {(r.sku, r.yw): float(r.demand) for r in fc_local.itertuples()}

    # ---- VP buyer detail (for "customer dropout" what-if) ----
    # Aggregates vp_input_detail.csv per buyer to {(sku, wk): qty}, summing
    # both 'on-top demand' and 'regular increase' rows since both feed the
    # forecast (see _load_ontop_from_csv). Excluded buyers (chosen by user
    # below the KPI strip) have their contribution subtracted from the
    # forecast used by projection + cover-after-arrival.
    vp_detail_path = DATA_DIR / "vp_input_detail.csv"
    vp_buyer_demand = {}
    all_vp_buyers = []
    if vp_detail_path.exists():
        try:
            vp_det = pd.read_csv(vp_detail_path)
            vp_det.columns = [str(c).strip() for c in vp_det.columns]
            if "buyer" in vp_det.columns and "sku" in vp_det.columns:
                cw_cols = [c for c in vp_det.columns
                           if isinstance(c, str) and c.startswith("CW")]
                buyers_seen = set()
                for _, r in vp_det.iterrows():
                    buyer = str(r.get("buyer", "")).strip()
                    sku = r.get("sku")
                    if not buyer or buyer.lower() == "nan" or pd.isna(sku):
                        continue
                    buyers_seen.add(buyer)
                    for col in cw_cols:
                        try:
                            wk = int(col[2:])
                            v = float(r[col])
                        except (TypeError, ValueError):
                            continue
                        if not v or pd.isna(v):
                            continue
                        vp_buyer_demand.setdefault(buyer, {})
                        vp_buyer_demand[buyer][(sku, wk)] = (
                            vp_buyer_demand[buyer].get((sku, wk), 0.0) + v
                        )
                all_vp_buyers = sorted(buyers_seen)
        except Exception:
            pass

    # Read excluded buyers from session state. Widget is rendered below the
    # KPI strip (just before the chart). Reading session_state up here lets
    # us bake the exclusion into the projection + cover walks within the
    # SAME render (Streamlit reruns on widget interaction → top reads fresh).
    excluded_buyers = st.session_state.get("scen_excluded_buyers", [])

    if excluded_buyers and vp_buyer_demand:
        excluded_per_skuwk = {}
        for b in excluded_buyers:
            for (sku_x, wk_x), q in vp_buyer_demand.get(b, {}).items():
                excluded_per_skuwk[(sku_x, wk_x)] = (
                    excluded_per_skuwk.get((sku_x, wk_x), 0.0) + q
                )
        adj_fc_lookup = dict(fc_lookup)
        for k in list(adj_fc_lookup.keys()):
            k_sku, k_yw = k
            k_wk = k_yw % 100
            excl_q = excluded_per_skuwk.get((k_sku, k_wk), 0.0)
            if excl_q > 0:
                adj_fc_lookup[k] = max(0.0, adj_fc_lookup[k] - excl_q)
    else:
        adj_fc_lookup = fc_lookup

    # ---- UI: filters ----
    c1, c2, c3 = st.columns([3, 2, 2])

    # Default suppliers = top 3 by € in next 8 weeks
    next_yws = {scen_week_step(cy, cw, i)[0] * 100 + scen_week_step(cy, cw, i)[1]
                for i in range(1, 9)}
    inc_m["yw"] = inc_m["year"] * 100 + inc_m["week"]
    inc_m["eur"] = inc_m["qty"] * inc_m["sku"].map(cost_map).fillna(0)
    nearterm = inc_m[inc_m["yw"].isin(next_yws)]
    sup_rank = nearterm.groupby("supplier")["eur"].sum().sort_values(ascending=False)
    all_suppliers = sorted(inc_m["supplier"].unique().tolist())
    default_suppliers = sup_rank.head(3).index.tolist() if len(sup_rank) else []

    with c1:
        chosen_suppliers = st.multiselect(
            "Supplier(s) in scope",
            options=all_suppliers,
            default=default_suppliers,
            help="POs from selected suppliers can be cancelled/postponed. "
                 "Other suppliers' POs stay as-scheduled but appear in the projection.",
            key="scen_suppliers",
        )

    with c2:
        win_start = st.number_input("Window start (CW)", 1, 52, value=cw + 2,
                                    help="First week eligible for action. Defaults to CW+2.",
                                    key="scen_win_start")
        win_end = st.number_input("Window end (CW)", 1, 52, value=min(cw + 7, 52),
                                  help="Last week eligible for action.",
                                  key="scen_win_end")

    with c3:
        stock_basis = st.radio("Stock basis",
                               ["WH only", "WH + stores"],
                               horizontal=True,
                               help="Decision basis for cover calc. Cash "
                                    "projection always uses WH + stores (full-company).",
                               key="scen_basis")

    # Initialize policy defaults in session_state (preset buttons mutate these,
    # so widgets must read from session_state via key= only — no value= arg or
    # Streamlit raises StreamlitAPIException).
    st.session_state.setdefault("scen_postpone_trigger", 4)
    st.session_state.setdefault("scen_postpone_delay", 4)
    st.session_state.setdefault("scen_cancel_on", True)
    st.session_state.setdefault("scen_cancel_threshold", 16)

    # ---- Presets ----
    pcols = st.columns([1, 1, 1, 1])
    if pcols[0].button("🍃 Gentle (postpone only)", use_container_width=True):
        st.session_state["scen_cancel_on"] = False
        st.session_state["scen_postpone_trigger"] = 4
        st.session_state["scen_postpone_delay"] = 4
        st.rerun()
    if pcols[1].button("⚖️ Balanced (cancel ≥16w current cover)", use_container_width=True):
        st.session_state["scen_cancel_on"] = True
        st.session_state["scen_cancel_threshold"] = 16
        st.session_state["scen_postpone_trigger"] = 4
        st.session_state["scen_postpone_delay"] = 4
        st.rerun()
    if pcols[2].button("🔥 Aggressive (cancel ≥10w current cover)", use_container_width=True):
        st.session_state["scen_cancel_on"] = True
        st.session_state["scen_cancel_threshold"] = 10
        st.session_state["scen_postpone_trigger"] = 3
        st.session_state["scen_postpone_delay"] = 4
        st.rerun()
    pcols[3].caption("Both cancel & postpone gate on **current cover** "
                     "(stock now ÷ forward demand). Cancel ⇒ SKU must already "
                     "have ≥ threshold weeks of cover without the PO arriving — "
                     "protects against cancel-then-stockout.")

    # ---- Policy knobs ----
    pol1, pol2, pol3, pol4 = st.columns(4)
    with pol1:
        postpone_trigger = st.slider(
            "Postpone trigger (weeks of current cover)", 1, 12,
            key="scen_postpone_trigger",
            help="POSTPONE if current cover ≥ this. Current cover = today's "
                 "stock ÷ next-13-weeks demand walked forward.",
        )
    with pol2:
        postpone_delay = st.slider(
            "Postpone delay (weeks)", 1, 12,
            key="scen_postpone_delay",
            help="How many weeks to push POSTPONE deliveries back.",
        )
    with pol3:
        cancel_on = st.checkbox(
            "Enable cancel",
            key="scen_cancel_on",
            help="If off, every action is POSTPONE or PRODUCE.",
        )
    with pol4:
        # Cancel threshold must be ≥ postpone trigger so the bands don't
        # overlap weirdly. If session_state has a stale value below the new
        # postpone floor, bump it up.
        cancel_min = max(int(postpone_trigger), 4)
        if st.session_state["scen_cancel_threshold"] < cancel_min:
            st.session_state["scen_cancel_threshold"] = cancel_min
        cancel_threshold = st.slider(
            "Cancel threshold (weeks of current cover)", cancel_min, 52,
            key="scen_cancel_threshold",
            disabled=not cancel_on,
            help="CANCEL if SKU already has ≥ this many weeks of CURRENT cover "
                 "(without the incoming PO). Cancelling a thin SKU with low "
                 "current cover risks a stockout during lead time — this gate "
                 "prevents that.",
        )
    cancel_threshold_eff = cancel_threshold if cancel_on else None

    target_eur = st.number_input("Target ceiling (€)", min_value=1_000_000,
                                 max_value=20_000_000, value=5_000_000, step=100_000,
                                 help="Drawn as a horizontal line on the projection. "
                                      "Used for the 'under target?' KPI.",
                                 key="scen_target")

    # ---- Resolve PO window ----
    if win_start > win_end:
        st.error("Window start must be ≤ window end.")
        return
    window_yws = {cy * 100 + w for w in range(int(win_start), int(win_end) + 1)}
    in_scope = inc_m[inc_m["supplier"].isin(chosen_suppliers)
                     & inc_m["yw"].isin(window_yws)].copy()
    if len(in_scope) == 0:
        st.info("No POs match the supplier + window selection.")
        return

    # ---- Build per-PO classification ----
    HORIZON = 13
    walk_weeks = scen_week_series(cy, cw, HORIZON)        # CW{cw}..CW{cw+12}
    proj_weeks = scen_week_series(cy, cw, 14)             # CW{cw}..CW{cw+13}
    proj_walk = scen_week_series(cy, cw, HORIZON + 5)     # walk a bit further

    def cover_basis(sku):
        if stock_basis == "WH only":
            return float(wh_map.get(sku, 0))
        return float(wh_map.get(sku, 0)) + float(stores_map.get(sku, 0))

    lt_map = dict(zip(master["sku"], master["lead_time_weeks"]))

    rows = []
    for _, po in in_scope.iterrows():
        sku = po["sku"]
        py, pw = int(po["year"]), int(po["week"])
        qty = float(po["qty"])
        cost = float(cost_map.get(sku, 0.0))
        lt = float(lt_map.get(sku, 8.0))

        series_now = [fc_lookup.get((sku, y * 100 + w), 0.0)
                      for (y, w) in scen_week_series(cy, cw, HORIZON)]
        cover_now = scen_real_weeks_cover(cover_basis(sku), series_now)

        series_post = [fc_lookup.get((sku, y * 100 + w), 0.0)
                       for (y, w) in scen_week_series(py, pw, HORIZON)]
        cover_post = scen_real_weeks_cover(cover_basis(sku) + qty, series_post)

        action = scen_classify(cover_now, cover_post,
                               cancel_threshold_eff, postpone_trigger)

        ny, nw = scen_week_step(py, pw, postpone_delay)
        rows.append({
            "sku": sku,
            "name": po.get("name", "") or "",
            "tier": po.get("tier", "") or "",
            "category": po.get("category", "") or "",
            "supplier": po["supplier"],
            "po_year": py, "po_week": pw,
            "po_label": f"CW{pw}",
            "qty": int(qty),
            "cost_price": round(cost, 2),
            "eur_value": round(qty * cost, 0),
            "lead_time_wk": round(lt, 1),
            "cover_now_wk": round(cover_now, 1),
            "cover_vs_lt": round(cover_now - lt, 1),
            "cover_post_delivery_wk": round(cover_post, 1),
            "action_raw": action,
            "new_week_postpone": f"CW{nw}",
        })
    pdf = pd.DataFrame(rows)

    # ---- Sanity check ----
    pdf["action"], flips = scen_sanity_check(
        pdf, "action_raw", inc_m, fc_lookup, wh_map, walk_weeks, postpone_delay,
    )

    def _auto_new_week(r):
        if r["action"] == "CANCEL":
            return ""
        if r["action"] == "POSTPONE":
            return r["new_week_postpone"]
        return r["po_label"]
    pdf["auto_new_week"] = pdf.apply(_auto_new_week, axis=1)
    pdf["auto_action"] = pdf["action"]

    if flips:
        st.warning(f"⚠️ Sanity check un-postponed {len(flips)} PO(s) "
                   "where stacking would have caused a stockout. "
                   "These are now PRODUCE (accept on original date). "
                   "Manual override below ignores the sanity check.")

    # ---- Helpers shared by editor + projection (defined once, used twice) ----
    def _po_key(r):
        return (r["sku"], int(r["po_year"]), int(r["po_week"]))

    def _resolve_target(po_y, po_w, new_cw):
        """Map an integer override CW to (year, week).
          - new_cw > po_w → same year (normal in-year postpone)
          - new_cw < po_w → next year (rolled over, e.g. CW48 PO postponed to CW3)
          - new_cw == po_w → no postpone within year, fall back to default delay
            (defensive: this happens when user toggles action without picking
            a target week, and we don't want a hidden 52-week shift).
        """
        new_cw = int(new_cw)
        if new_cw == po_w:
            return scen_week_step(po_y, po_w, postpone_delay)
        if new_cw < po_w:
            return (po_y + 1, new_cw)
        return (po_y, new_cw)

    overrides_store = st.session_state.setdefault("scen_overrides", {})

    def _resolve_effective(ov_store):
        """For each in-scope PO, return:
          - eff_actions[(sku, py, pw)] = effective action (auto or override)
          - override_targets[(sku, py, pw)] = (ny, nw) only for POs with an
            explicit override target — fed to projection engine.
        """
        eff = {}
        targets = {}
        for _, r in pdf.iterrows():
            key = _po_key(r)
            py, pw = int(r["po_year"]), int(r["po_week"])
            ov = ov_store.get(key)
            if ov is None:
                eff[key] = r["auto_action"]
                continue
            act = ov["action"]
            eff[key] = act
            if act == "POSTPONE":
                new_cw = ov.get("new_cw")
                if new_cw is None:
                    targets[key] = scen_week_step(py, pw, postpone_delay)
                else:
                    targets[key] = _resolve_target(py, pw, new_cw)
        return eff, targets

    def _arrival_week(sku, py, pw, eff_actions, override_targets):
        """Effective arrival (year, week) for a PO. None if cancelled."""
        key = (sku, py, pw)
        act = eff_actions.get(key, "PRODUCE")
        if act == "CANCEL":
            return None
        if act == "POSTPONE":
            if key in override_targets:
                return override_targets[key]
            return scen_week_step(py, pw, postpone_delay)
        return (py, pw)

    # In-scope PO keys — out-of-scope POs in inc_m keep their scheduled week
    in_scope_keys = set(_po_key(r) for _, r in pdf.iterrows())
    cover_walk_horizon = HORIZON + 10

    def _compute_cover_after_arrival(eff_actions, override_targets):
        """Walk per-SKU stock forward from today, adding all inflows (in-scope
        with effective actions + out-of-scope as-scheduled), subtracting demand.
        For each PO, return cover (forward weeks of demand) measured from the
        moment AFTER that PO arrives. Demand-only walk after arrival — future
        POs are NOT counted, so the number is comparable across rows.

        Multiple POs for the same SKU correctly stack: a second PO sees the
        stock left over from the first one's arrival, minus consumed demand."""
        inflow_by_sku_yw = {}
        for _, r in pdf.iterrows():
            sku = r["sku"]
            py, pw = int(r["po_year"]), int(r["po_week"])
            arrival = _arrival_week(sku, py, pw, eff_actions, override_targets)
            if arrival is None:
                continue
            ay, aw = arrival
            yw = ay * 100 + aw
            inflow_by_sku_yw.setdefault(sku, {})
            inflow_by_sku_yw[sku][yw] = inflow_by_sku_yw[sku].get(yw, 0.0) + float(r["qty"])
        for _, p in inc_m.iterrows():
            okey = (p["sku"], int(p["year"]), int(p["week"]))
            if okey in in_scope_keys:
                continue
            yw = int(p["year"]) * 100 + int(p["week"])
            inflow_by_sku_yw.setdefault(p["sku"], {})
            inflow_by_sku_yw[p["sku"]][yw] = (
                inflow_by_sku_yw[p["sku"]].get(yw, 0.0) + float(p["qty"])
            )

        walk_yws = [(y, w) for (y, w) in scen_week_series(cy, cw, cover_walk_horizon)]
        yw_index = {y * 100 + w: i for i, (y, w) in enumerate(walk_yws)}

        def _cover_for(r):
            sku = r["sku"]
            py, pw = int(r["po_year"]), int(r["po_week"])
            arrival = _arrival_week(sku, py, pw, eff_actions, override_targets)
            if arrival is None:
                return None  # CANCEL
            ay, aw = arrival
            arrival_yw = ay * 100 + aw
            if arrival_yw not in yw_index:
                return None  # arrival beyond walk horizon
            sku_inflows = inflow_by_sku_yw.get(sku, {})
            s = cover_basis(sku)
            stop_i = yw_index[arrival_yw]
            for i in range(stop_i + 1):
                yw_key = walk_yws[i][0] * 100 + walk_yws[i][1]
                s += sku_inflows.get(yw_key, 0.0)
                s -= adj_fc_lookup.get((sku, yw_key), 0.0)
                if s < 0:
                    s = 0.0
            fwd = [adj_fc_lookup.get((sku, y * 100 + w), 0.0)
                   for (y, w) in scen_week_series(ay, aw, HORIZON)[1:]]
            return scen_real_weeks_cover(s, fwd)

        return pdf.apply(_cover_for, axis=1)

    # ---- Pre-editor resolution: compute cover_after_arrival for editor display ----
    pre_eff, pre_targets = _resolve_effective(overrides_store)
    pdf["cover_after_arrival_wk"] = _compute_cover_after_arrival(
        pre_eff, pre_targets,
    ).round(1)

    # ---- Per-PO manual override editor ----
    # Lets the user lock a specific action and new delivery CW per PO,
    # overriding the auto classifier (and the sanity check). Keyed on
    # (sku, po_year, po_week) so edits survive filter changes.
    st.subheader("Per-PO scenario editor")
    st.caption("Auto-prijedlog dolazi iz policy klizača iznad. Možeš ručno "
               "promijeniti **akciju** po PO retku i, ako je POSTPONE, upisati "
               "ciljni **New CW** (tjedan kad očekuješ docking). "
               "**Cover after arrival** zbraja sve dolaske istog SKU-a "
               "(kumulativni stock walk).")

    ec = st.columns([1, 1, 2, 2])
    if ec[0].button("🧹 Reset overrides", key="scen_reset_overrides"):
        overrides_store.clear()
        # data_editor widget state is keyed separately; bump the key suffix
        st.session_state["scen_edit_nonce"] = st.session_state.get("scen_edit_nonce", 0) + 1
        st.rerun()
    f_supplier = ec[1].selectbox(
        "Filter supplier",
        ["(all)"] + sorted(pdf["supplier"].unique().tolist()),
        key="scen_edit_supplier_filter",
    )
    f_action = ec[2].multiselect(
        "Filter auto-action",
        ["CANCEL", "POSTPONE", "PRODUCE"],
        default=["CANCEL", "POSTPONE", "PRODUCE"],
        key="scen_edit_action_filter",
    )
    sku_search = ec[3].text_input(
        "Search SKU / name",
        value="",
        placeholder="e.g. POL12345 or whey",
        key="scen_edit_search",
    ).strip().lower()

    # Build the editable frame. Pre-fill override columns from session state
    # so edits survive reruns even when filters change row visibility.
    edit_rows = []
    for _, r in pdf.iterrows():
        key = _po_key(r)
        ov = overrides_store.get(key, {})
        # Default New CW = po_week + postpone_delay (the would-be postpone target).
        # If we defaulted to po_week itself, switching action → POSTPONE without
        # touching New CW would resolve the target into next year (because
        # new_cw == po_week trips _resolve_target's roll-over branch), which
        # silently pushes the PO out of the projection walk.
        _post_y, _post_w = scen_week_step(int(r["po_year"]), int(r["po_week"]),
                                          postpone_delay)
        auto_new_cw_int = int(_post_w)
        # Override stored new_cw takes precedence if present and valid.
        # Skip stored value == po_week — that's stale state from the earlier
        # UX where the editor defaulted New CW to po_week (a user flipping
        # only the action saved that as the target, which then rolled the
        # PO into next year). Treat it as "no explicit target".
        stored_new_cw = ov.get("new_cw")
        if stored_new_cw is not None:
            try:
                sv = int(stored_new_cw)
                if sv != int(r["po_week"]):
                    auto_new_cw_int = sv
            except (ValueError, TypeError):
                pass
        cov_after = r["cover_after_arrival_wk"]
        edit_rows.append({
            **{k: r[k] for k in ("sku", "name", "tier", "supplier", "po_label",
                                  "qty", "eur_value", "cover_now_wk")},
            "cover_after_arrival_wk": (float(cov_after) if pd.notna(cov_after)
                                       else float("nan")),
            "auto_action": r["auto_action"],
            "Override action": ov.get("action", "AUTO"),
            "New CW": auto_new_cw_int,
            "_po_year": int(r["po_year"]),
            "_po_week": int(r["po_week"]),
        })
    edit_df = pd.DataFrame(edit_rows)

    # Apply display filters
    view = edit_df.copy()
    if f_supplier != "(all)":
        view = view[view["supplier"] == f_supplier]
    if f_action:
        view = view[view["auto_action"].isin(f_action)]
    if sku_search:
        mask = (view["sku"].astype(str).str.lower().str.contains(sku_search)
                | view["name"].astype(str).str.lower().str.contains(sku_search))
        view = view[mask]
    view = view.sort_values(["auto_action", "eur_value"], ascending=[True, False])
    # Reset index so data_editor's stored edits (which Streamlit tracks by
    # row position) reliably map back to the same logical row on rerun.
    view = view.reset_index(drop=True)

    if len(view) == 0:
        st.info("Nema PO redaka koji odgovaraju filterima.")
    else:
        editor_key = f"scen_editor_{st.session_state.get('scen_edit_nonce', 0)}"
        edited = st.data_editor(
            view,
            key=editor_key,
            use_container_width=True,
            hide_index=True,
            height=420,
            column_order=["sku", "name", "tier", "supplier", "po_label", "qty",
                          "eur_value", "cover_now_wk", "cover_after_arrival_wk",
                          "auto_action", "Override action", "New CW"],
            column_config={
                "sku": st.column_config.TextColumn("SKU", disabled=True),
                "name": st.column_config.TextColumn("Name", disabled=True),
                "tier": st.column_config.TextColumn("Tier", disabled=True),
                "supplier": st.column_config.TextColumn("Supplier", disabled=True),
                "po_label": st.column_config.TextColumn("PO CW", disabled=True),
                "qty": st.column_config.NumberColumn("Qty", disabled=True, format="%d"),
                "eur_value": st.column_config.NumberColumn("€ value", disabled=True, format="€%.0f"),
                "cover_now_wk": st.column_config.NumberColumn("Cover now (wk)", disabled=True, format="%.1f"),
                "cover_after_arrival_wk": st.column_config.NumberColumn(
                    "Cover after arrival (wk)", disabled=True, format="%.1f",
                    help="Kumulativni cover: hodaj stock od danas, zbroji SVE "
                         "dolaske SKU-a do tjedna ovog PO-a, oduzmi potražnju, "
                         "pa izračunaj forward cover. Refleсtira aktivne override-e "
                         "(osvježi se nakon sljedeće interakcije).",
                ),
                "auto_action": st.column_config.TextColumn("Auto", disabled=True,
                                                            help="Prijedlog policy klizača"),
                "Override action": st.column_config.SelectboxColumn(
                    "Override action",
                    options=["AUTO", "PRODUCE", "POSTPONE", "CANCEL"],
                    required=True,
                    help="AUTO = koristi auto-prijedlog. Inače ručno zaključavaš akciju.",
                ),
                "New CW": st.column_config.NumberColumn(
                    "New CW (postpone target)",
                    min_value=1, max_value=52, step=1,
                    help="Ciljni tjedan dockinga ako je akcija POSTPONE. "
                         "Ignorira se za CANCEL / PRODUCE / AUTO.",
                ),
            },
        )
        # Persist edits back to the override store (keyed by PO, survives filters)
        for _, row in edited.iterrows():
            key = (row["sku"], int(row["_po_year"]), int(row["_po_week"]))
            ov_action = str(row["Override action"]) if pd.notna(row["Override action"]) else "AUTO"
            if ov_action not in {"AUTO", "PRODUCE", "POSTPONE", "CANCEL"}:
                ov_action = "AUTO"
            try:
                new_cw = int(row["New CW"]) if pd.notna(row["New CW"]) else None
            except (ValueError, TypeError):
                new_cw = None
            if ov_action == "AUTO":
                overrides_store.pop(key, None)
            else:
                overrides_store[key] = {"action": ov_action, "new_cw": new_cw}

    n_overrides = len(overrides_store)
    if n_overrides:
        st.caption(f"📝 {n_overrides} ručnih override-a aktivno. "
                   "Reset overrides briše sve i vraća na auto.")

    # ---- Post-editor resolution: rebuild effective state with the very latest
    # overrides (may include edits made this render) for projection + summary.
    post_eff, postpone_targets = _resolve_effective(overrides_store)

    eff_actions = []
    eff_new_weeks = []
    for _, r in pdf.iterrows():
        key = _po_key(r)
        py, pw = int(r["po_year"]), int(r["po_week"])
        act = post_eff[key]
        if key in postpone_targets:
            ny, nw = postpone_targets[key]
            new_wk_label = f"CW{nw}"
        elif act == "CANCEL":
            new_wk_label = ""
        elif act == "POSTPONE":
            # auto POSTPONE (no override target) — use auto-default new week
            new_wk_label = r["auto_new_week"]
        else:  # PRODUCE
            new_wk_label = r["po_label"]
        eff_actions.append(act)
        eff_new_weeks.append(new_wk_label)
    pdf["action"] = eff_actions
    pdf["new_delivery_week"] = eff_new_weeks
    pdf["is_override"] = [_po_key(r) in overrides_store for _, r in pdf.iterrows()]

    # Refresh cover_after_arrival with post-edit state (matches summary + chart)
    pdf["cover_after_arrival_wk"] = _compute_cover_after_arrival(
        post_eff, postpone_targets,
    ).round(1)

    # ---- Cash projection ----
    scen_actions = {(r["sku"], int(r["po_year"]), int(r["po_week"])): r["action"]
                    for _, r in pdf.iterrows()}

    # Anchor = current WH+stores stock value
    current_eur = sum(
        (wh_map.get(s, 0) + stores_map.get(s, 0)) * cost_map.get(s, 0.0)
        for s in (set(wh_map.keys()) | set(stores_map.keys()))
    )

    baseline_proj = scen_company_eur_projection(
        inc_m, {}, cost_map, adj_fc_lookup, all_skus_for_outflow,
        proj_walk, current_eur, postpone_delay,
    )
    scenario_proj = scen_company_eur_projection(
        inc_m, scen_actions, cost_map, adj_fc_lookup, all_skus_for_outflow,
        proj_walk, current_eur, postpone_delay,
        postpone_targets=postpone_targets,
    )

    # ---- KPI strip ----
    st.subheader("Projection vs baseline")
    proj_keys = proj_weeks
    base_peak_yw = max(proj_keys, key=lambda k: baseline_proj.get(k, 0))
    base_peak = baseline_proj.get(base_peak_yw, 0)
    scen_peak_yw = max(proj_keys, key=lambda k: scenario_proj.get(k, 0))
    scen_peak = scenario_proj.get(scen_peak_yw, 0)

    cancel_eur = float(pdf[pdf["action"] == "CANCEL"]["eur_value"].sum())
    postpone_eur = float(pdf[pdf["action"] == "POSTPONE"]["eur_value"].sum())
    total_impact = cancel_eur + postpone_eur

    k = st.columns(5)
    k[0].metric("Baseline peak €", f"€{base_peak:,.0f}")
    k[0].caption(f"at CW{base_peak_yw[1]}")
    k[1].metric("Scenario peak €", f"€{scen_peak:,.0f}",
                f"€{scen_peak - base_peak:+,.0f}",
                delta_color="inverse")
    k[1].caption(f"at CW{scen_peak_yw[1]}")
    k[2].metric("€ unlocked (cancel + postpone)", f"€{total_impact:,.0f}")
    k[2].caption(f"cancel €{cancel_eur:,.0f} · postpone €{postpone_eur:,.0f}")
    under = scen_peak < target_eur
    k[3].metric("Under target?", "✅ yes" if under else "❌ no")
    k[3].caption(f"target €{target_eur:,.0f}")
    k[4].metric("POs affected", f"{int((pdf['action'].isin(['CANCEL', 'POSTPONE'])).sum())}")
    k[4].caption(f"cancel {int((pdf['action'] == 'CANCEL').sum())} · "
                 f"postpone {int((pdf['action'] == 'POSTPONE').sum())}")

    # ---- VP customer-dropout exclusion (just before the chart) ----
    # Reads/writes session_state["scen_excluded_buyers"] — its value is read
    # at the top of the page and baked into adj_fc_lookup, so on the next
    # rerun the chart + KPIs + cover_after_arrival reflect the exclusion.
    if all_vp_buyers:
        excl_col1, excl_col2 = st.columns([3, 2])
        with excl_col1:
            st.multiselect(
                "VP kupci koji NEĆE povući (njihove on-top + regular increase "
                "kvantitete se brišu iz potražnje)",
                options=all_vp_buyers,
                key="scen_excluded_buyers",
                help="Odabir gazi potražnju u projekciji i u Cover after arrival. "
                     "Auto-klasifikator i Cover now (wk) ostaju na originalnom "
                     "forecast-u, da prijedlozi ostanu stabilni dok testiraš scenarije.",
            )
        with excl_col2:
            if excluded_buyers:
                # Show total € of demand removed in projection window for quick sanity
                excluded_eur = 0.0
                proj_yw_set = {y * 100 + w for (y, w) in proj_walk}
                for b in excluded_buyers:
                    for (sk, wk_n), q in vp_buyer_demand.get(b, {}).items():
                        # Match any (sku, year, wk) in projection window
                        for (k_sku, k_yw), _ in fc_lookup.items():
                            if k_sku == sk and (k_yw % 100) == wk_n and k_yw in proj_yw_set:
                                excluded_eur += q * cost_map.get(sk, 0.0)
                                break
                st.metric("Skinuto iz potražnje (€, projection window)",
                          f"€{excluded_eur:,.0f}",
                          help="Suma (qty × cost) odabranih kupaca u prozoru "
                               "projekcije. Idealno se vidi kao 'višak stocka' na grafu.")
            else:
                st.caption("Nitko nije isključen — projekcija koristi puni forecast.")
    else:
        st.caption("ℹ️ `vp_input_detail.csv` nije pronađen ili nema `buyer` "
                   "stupca — VP customer-dropout filter neaktivan.")

    # ---- Projection chart ----
    x_labels = [f"CW{w}" for (_, w) in proj_keys]
    base_series = [baseline_proj.get(k, 0) for k in proj_keys]
    scen_series = [scenario_proj.get(k, 0) for k in proj_keys]

    fig = go.Figure()
    fig.add_trace(go.Scatter(x=x_labels, y=base_series, name="Baseline",
                             mode="lines+markers",
                             line=dict(color="#888", width=2, dash="dot"),
                             marker=dict(size=6)))
    fig.add_trace(go.Scatter(x=x_labels, y=scen_series, name="Scenario",
                             mode="lines+markers+text",
                             line=dict(color="#2F5496", width=2.5),
                             marker=dict(size=7),
                             text=[f"€{v / 1e6:.2f}M" for v in scen_series],
                             textposition="top center", textfont=dict(size=9)))
    fig.add_hline(y=target_eur, line=dict(color="#c0392b", width=1.5, dash="dash"),
                  annotation_text=f"Target €{target_eur / 1e6:.1f}M",
                  annotation_position="right")
    fig.update_layout(
        height=420,
        yaxis=dict(title="€", tickformat=","),
        margin=dict(l=60, r=20, t=30, b=40),
        hovermode="x unified",
        plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
        legend=dict(orientation="h", y=1.08),
    )
    fig.update_yaxes(showgrid=True, gridcolor="rgba(200,200,200,0.3)")
    st.plotly_chart(fig, use_container_width=True)

    # ---- Per-PO summary (effective) ----
    with st.expander("📋 Effective per-PO summary (auto + ručni override)",
                     expanded=False):
        summary_view = pdf[["sku", "name", "tier", "category", "supplier", "po_label", "qty",
                            "cost_price", "eur_value", "cover_now_wk",
                            "cover_after_arrival_wk",
                            "auto_action", "action", "new_delivery_week", "is_override"]].copy()
        summary_view = summary_view.sort_values(
            ["is_override", "action", "eur_value"], ascending=[False, True, False],
        )
        summary_view = summary_view.rename(columns={
            "name": "Name", "po_label": "PO CW", "qty": "Qty",
            "cost_price": "Cost €", "eur_value": "€ value",
            "cover_now_wk": "Cover now (wk)",
            "cover_after_arrival_wk": "Cover after arrival (wk)",
            "auto_action": "Auto", "action": "Final action",
            "new_delivery_week": "New CW",
            "is_override": "Manual?",
        })
        st.dataframe(summary_view, use_container_width=True, hide_index=True, height=360)

    # ---- Downloads ----
    st.subheader("Supplier-ready downloads")
    dcols = st.columns(3)

    def _full_table_excel():
        """Full per-PO editor view (all actions + cover + overrides) as Excel."""
        sub = pdf.copy().sort_values(
            ["is_override", "action", "eur_value"],
            ascending=[False, True, False],
        )
        sub["override"] = sub["is_override"].map({True: "manual", False: "auto"})
        out_cols = ["sku", "name", "tier", "category", "supplier",
                    "po_label", "qty", "cost_price", "eur_value",
                    "cover_now_wk", "cover_after_arrival_wk",
                    "auto_action", "action", "new_delivery_week", "override"]
        out_cols = [c for c in out_cols if c in sub.columns]
        rename = {
            "po_label": "PO CW", "qty": "Qty", "cost_price": "Cost €",
            "eur_value": "€ value",
            "cover_now_wk": "Cover now (wk)",
            "cover_after_arrival_wk": "Cover after arrival (wk)",
            "auto_action": "Auto action", "action": "Final action",
            "new_delivery_week": "New CW",
        }
        from io import BytesIO
        buf = BytesIO()
        with pd.ExcelWriter(buf, engine="openpyxl") as xw:
            sub[out_cols].rename(columns=rename).to_excel(
                xw, sheet_name="per_po", index=False,
            )
        return buf.getvalue()

    def _supplier_excel(action_label):
        sub = pdf[pdf["action"] == action_label].copy()
        if len(sub) == 0:
            return None
        sub = sub.sort_values(["supplier", "tier", "eur_value"],
                              ascending=[True, True, False])
        sub["original_delivery_week"] = sub["po_label"]
        sub["new_delivery_week_or_blank"] = sub["new_delivery_week"].where(
            sub["action"] == "POSTPONE", "")
        sub["override"] = sub["is_override"].map({True: "manual", False: "auto"})
        sub["our_notes"] = ""
        out_cols = ["sku", "name", "tier", "supplier", "original_delivery_week", "qty",
                    "cost_price", "eur_value", "action",
                    "new_delivery_week_or_blank", "override", "our_notes"]
        from io import BytesIO
        buf = BytesIO()
        with pd.ExcelWriter(buf, engine="openpyxl") as xw:
            sub[out_cols].to_excel(xw, sheet_name=action_label, index=False)
        return buf.getvalue()

    cancel_xlsx = _supplier_excel("CANCEL")
    postpone_xlsx = _supplier_excel("POSTPONE")
    full_xlsx = _full_table_excel()
    ts = datetime.now().strftime("%Y%m%d_%H%M")
    dcols[0].download_button(
        f"📊 Cijela tablica ({len(pdf)} POs)",
        data=full_xlsx,
        file_name=f"scenario_per_po_{ts}.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        use_container_width=True,
        help="Sve PO-ove iz editora (auto + override + cover) u jednom Excelu.",
    )
    if cancel_xlsx:
        dcols[1].download_button(
            f"📥 Cancel list ({int((pdf['action'] == 'CANCEL').sum())} POs)",
            data=cancel_xlsx,
            file_name=f"po_cancel_list_{ts}.xlsx",
            mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            use_container_width=True,
        )
    else:
        dcols[1].info("No POs flagged CANCEL.")
    if postpone_xlsx:
        dcols[2].download_button(
            f"📥 Postpone list ({int((pdf['action'] == 'POSTPONE').sum())} POs)",
            data=postpone_xlsx,
            file_name=f"po_postpone_list_{ts}.xlsx",
            mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            use_container_width=True,
        )
    else:
        dcols[2].info("No POs flagged POSTPONE.")

    with st.expander("Methodology & assumptions"):
        st.markdown(f"""
- **Real weekly demand** drives the cover calc — no smoothing. Walk-forward
  through the next {HORIZON} weeks of forecast for each SKU.
- **Cover now** = today's stock ({stock_basis.lower()}) vs forward demand.
  Walked week-by-week, returns fractional weeks. **This drives both
  POSTPONE and CANCEL classification.**
- **Cancel rule:** CANCEL only if `cover_now ≥ cancel_threshold` — i.e. the
  SKU already has enough stock to survive without the PO. Protects against
  the "cancel & then stockout during lead time" failure mode.
- **Postpone rule:** POSTPONE if `cover_now ≥ postpone_trigger` (and not
  already a CANCEL). Postpone delay = {postpone_delay} weeks.
- **Lead time column** is informational — if `Cover − LT` is small or
  negative, even POSTPONE may be risky; the sanity check will catch most of
  these but you can spot-review SKUs near the threshold.
- **Cover after arrival (wk)** = kumulativni walk-forward stock. Krene od
  današnjeg stocka, dodaje SVE dolaske (in-scope POs s efektivnom akcijom +
  out-of-scope POs as-scheduled), oduzima potražnju. Na tjednu dolaska
  ovog PO-a uzme se stanje stocka i podijeli s budućom potražnjom (bez
  dodatnih dolazaka) → forward weeks of cover. **Ako za isti SKU stiže
  više PO-a, kasniji vidi stanje koje je ostalo nakon ranijih**. Reagira
  na CANCEL / POSTPONE / New CW override-e iz editora.
- **Sanity check**: if multiple POSTPONEs for the same SKU stack to cause a
  stockout in the {HORIZON}-week horizon, the latest-week PO is flipped back
  to PRODUCE. Repeats until projection is clean.
- **Cash projection** uses full company demand × cost for the outflow side,
  not an average — peak weeks are visible.
- **Anchor**: CW{cw} of the projection is set to the current WH+stores stock
  value computed from `stock.csv` + `stock_stores*.csv` × `sku_costs.csv`.
- **Manual override (Per-PO scenario editor)**: zaključava akciju i ciljni
  tjedan dockinga za pojedini PO. Override gazi i auto-klasifikaciju i
  sanity check. Postavi `Override action` na AUTO da se vratiš na prijedlog.
""")


def page_supply_coverage():
    st.title("📋 Coverage table")
    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    params = sup_params()
    cov_rows, summary, _ = sup_build_coverage(stock, forecast, incoming, master,
                                              params, get_current_cw())

    c = st.columns([1, 1, 1, 1])
    sup = c[0].selectbox("Supplier", ["All"] + sorted([s for s in summary["supplier"].unique() if s]))
    cat = c[1].selectbox("Category", ["All"] + sorted([s for s in summary["category"].unique() if s]))
    tier = c[2].selectbox("Tier", ["All"] + sorted([s for s in summary["tier"].unique() if s]))
    flag_sel = c[3].selectbox("Flag", ["All", "Needs action", "order_now", "order_next_week", "ok"])

    df = summary.copy()
    if sup != "All": df = df[df["supplier"] == sup]
    if cat != "All": df = df[df["category"] == cat]
    if tier != "All": df = df[df["tier"] == tier]
    if flag_sel == "Needs action":
        df = df[df["flag"].isin(["order_now", "order_next_week"])]
    elif flag_sel != "All":
        df = df[df["flag"] == flag_sel]

    st.caption(f"{len(df)} SKUs · horizon {params['horizon']} wk · "
               f"FA {params['fa']:.0%} · Z {params['z']}")
    st.dataframe(df.sort_values(["flag", "coverage_wk_now"]),
                 use_container_width=True, height=380, hide_index=True)

    st.subheader("SKU detail — weekly coverage grid")
    if len(df):
        sel = st.selectbox("Pick SKU", df["sku"].tolist(), key="sup_cov_sel")
        if sel:
            block = cov_rows[cov_rows["sku"] == sel].copy()
            block["wk"] = block.apply(lambda r: f"W{int(r.week)}", axis=1)
            grid = pd.DataFrame({
                "Stock": block.set_index("wk")["stock"].round(0).astype(int),
                "Demand": block.set_index("wk")["demand"].round(0).astype(int),
                "Incoming": block.set_index("wk")["incoming"].round(0).astype(int),
                "Closing": block.set_index("wk")["closing"].round(0).astype(int),
                "Coverage wk": block.set_index("wk")["coverage_wk"].round(1),
            }).T
            st.dataframe(grid, use_container_width=True)

            info = df[df["sku"] == sel].iloc[0]
            st.info(f"Safety stock = **{info['safety_stock']:,}** · "
                    f"ROP = {info['rop']:,} · LT = {info['lt_weeks']:.0f} wk · "
                    f"MOQ = {int(info['moq']):,} · "
                    f"Suggested order qty = **{info['suggested_qty']:,}**")


def page_supply_alerts():
    st.title("🚨 Reorder alerts")
    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    params = sup_params()
    _, summary, _ = sup_build_coverage(stock, forecast, incoming, master,
                                       params, get_current_cw())

    alerts = summary[summary["flag"].isin(["order_now", "order_next_week"])].copy()
    alerts["flag_label"] = alerts["flag"].map({
        "order_now": "🔴 Order now",
        "order_next_week": "🟡 Next week",
    })
    alerts = alerts.sort_values(["flag", "coverage_wk_now"])

    st.caption(f"{len(alerts)} SKUs need action · grouped by supplier. "
               "Go to **Order entry** to commit order quantities.")

    if len(alerts) == 0:
        st.success("No reorder alerts — all SKUs covered within lead time.")
        return

    for supplier, group in alerts.groupby("supplier"):
        label = supplier or "(no supplier set)"
        with st.expander(f"{label} — {len(group)} SKUs", expanded=True):
            st.dataframe(
                group[["flag_label", "sku", "name", "tier", "lt_weeks",
                       "on_hand", "avg_weekly_demand", "safety_stock",
                       "coverage_wk_now", "moq", "suggested_qty"]],
                use_container_width=True, hide_index=True,
            )


def page_supply_order_entry():
    st.title("✏️ Order entry")
    st.caption("Enter final order quantity per SKU. Suggested value is pre-filled. "
               "Drafts are saved between sessions.")

    g = _sup_data_guard()
    if g is None:
        return
    stock, forecast, incoming, master = g
    params = sup_params()
    _, summary, _ = sup_build_coverage(stock, forecast, incoming, master,
                                       params, get_current_cw())
    alerts = summary[summary["flag"].isin(["order_now", "order_next_week"])].copy()

    if len(alerts) == 0:
        st.success("No SKUs need ordering this cycle.")
        return

    orders = sup_load_orders()

    edit_df = pd.DataFrame([{
        "sku": r["sku"],
        "name": r["name"],
        "supplier": r["supplier"],
        "flag": r["flag"],
        "suggested": r["suggested_qty"],
        "moq": r["moq"],
        "order_qty": int(orders.get(r["sku"], {}).get("qty", r["suggested_qty"])),
        "note": orders.get(r["sku"], {}).get("note", ""),
    } for _, r in alerts.iterrows()])

    edited = st.data_editor(
        edit_df,
        use_container_width=True,
        hide_index=True,
        column_config={
            "order_qty": st.column_config.NumberColumn("Order qty", min_value=0, step=100),
            "note": st.column_config.TextColumn("Note"),
            "suggested": st.column_config.NumberColumn("Suggested", disabled=True),
            "moq": st.column_config.NumberColumn("MOQ", disabled=True),
        },
        disabled=["sku", "name", "supplier", "flag"],
        num_rows="fixed",
        key="sup_order_editor",
    )

    col1, col2 = st.columns([1, 4])
    if col1.button("💾 Save draft", use_container_width=True, type="primary"):
        for _, r in edited.iterrows():
            orders[r["sku"]] = {
                "qty": int(r["order_qty"]),
                "supplier": r["supplier"],
                "moq": int(r["moq"]),
                "note": r["note"],
                "saved_at": datetime.now().isoformat(timespec="seconds"),
                "cw": f"CW{get_current_cw()[1]}",
            }
        sup_save_orders(orders)
        st.success(f"Saved {len(edited)} order lines to `{ORDERS_FILE.name}`")
    col2.caption("Order lines with qty = 0 are kept as draft but excluded from the download.")


def page_supply_download():
    st.title("📥 Download supply plan")
    orders = sup_load_orders()
    if not orders:
        st.info("No orders saved yet. Go to **Order entry** first.")
        return

    df = pd.DataFrame([
        {"sku": sku, **v} for sku, v in orders.items() if v.get("qty", 0) > 0
    ])
    if len(df) == 0:
        st.info("No order lines with qty > 0 yet.")
        return

    st.dataframe(df, use_container_width=True, hide_index=True)

    from io import BytesIO
    buf = BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as w:
        df.to_excel(w, sheet_name="Supply_plan", index=False)
    buf.seek(0)
    cy, cw = get_current_cw()
    filename = f"Supply_plan_CW{cw}_{datetime.now():%Y%m%d}.xlsx"
    st.download_button(
        "📥 Download Supply plan (Excel)",
        data=buf, file_name=filename,
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        type="primary", use_container_width=True,
    )


def page_supply_upload():
    st.title("📤 Upload supply data")
    st.caption("These files live in the shared `data/` folder. Existing files "
               "are overwritten on upload.")

    st.subheader("1 · Warehouse stock snapshot")
    st.caption("Columns: `sku, on_hand` (Croatian aliases: `Šifra, Zaliha` — auto-renamed). "
               "If the file also has a `store_code` / `Jedinica` column, only WH rows "
               "(`store_code == 1`) are kept — store rows are dropped here and belong "
               "in the Store stock uploader below. **WH only** drives Coverage / Reorder alerts.")
    up = st.file_uploader("stock.csv or .xlsx", type=["csv", "xlsx"], key="sup_up_stock")
    if up is not None:
        df = pd.read_excel(up) if up.name.endswith(".xlsx") else pd.read_csv(up)
        # Auto-normalise column names — ERP exports come in Croatian / encoded.
        # Match each target at most once (claimed set) so e.g. `Šifra` (item) and
        # `Šifra jed.` (location code) don't both collapse to `sku`.
        col_map = {}
        claimed = set()
        for c in df.columns:
            cl = str(c).strip().lower()
            target = None
            if cl in ("sku", "code", "šifra", "sifra", "šifra artikla", "sifra artikla"):
                target = "sku"
            elif "šifra art" in cl or "sifra art" in cl:
                target = "sku"
            elif cl in ("on_hand", "zaliha", "stock", "stanje", "qty",
                        "količina", "kolicina"):
                target = "on_hand"
            elif cl in ("store_code", "jedinica", "store_id",
                        "šifra jedinice", "sifra jedinice", "lokacija"):
                target = "store_code"
            if target and target not in claimed:
                col_map[c] = target
                claimed.add(target)
        df = df.rename(columns=col_map)
        df = df.loc[:, ~df.columns.duplicated()]
        if "sku" not in df.columns or "on_hand" not in df.columns:
            st.error(f"Could not find sku / on_hand columns. Got: {list(df.columns)}")
        else:
            df = df.dropna(subset=["sku"])
            df["on_hand"] = pd.to_numeric(df["on_hand"], errors="coerce").fillna(0).astype(int)
            raw_rows = len(df)

            # If file contains per-location rows, keep ONLY warehouse rows.
            # Polleo convention: store_code 1 = WH, anything else = store.
            store_rows_dropped = 0
            if "store_code" in df.columns:
                df["store_code"] = pd.to_numeric(df["store_code"], errors="coerce")
                before = len(df)
                df = df[df["store_code"] == 1]
                store_rows_dropped = before - len(df)

            if len(df) == 0:
                st.error("After filtering to WH (`store_code == 1`), no rows remain. "
                         "Check that your export contains warehouse rows, or remove the "
                         "`store_code` column if the file is already WH-only.")
            else:
                n_neg = int((df["on_hand"] < 0).sum())
                df = df[["sku", "on_hand"]]
                df = df.groupby("sku", as_index=False)["on_hand"].sum()
                # Drop SKUs that net to ≤0 (zero stock or negatives that overwhelmed positives)
                df = df[df["on_hand"] > 0]
                df.to_csv(DATA_DIR / "stock.csv", index=False)

                parts = [f"Saved stock.csv — {len(df)} SKUs with WH stock "
                         f"(raw rows: {raw_rows})"]
                if store_rows_dropped > 0:
                    parts.append(f"dropped {store_rows_dropped} non-WH (store) rows")
                if n_neg > 0:
                    parts.append(f"⚠️ {n_neg} rows had negative on_hand — netted into the sum")
                st.success(" · ".join(parts))
                clear_all_caches()

    st.divider()
    st.subheader("2 · Store stock snapshots (optional)")
    st.caption("Preferred: long format `sku, store_code, store_name, on_hand` "
               "(Croatian aliases: `šifra, jedinica, naziv_jedinice, zaliha`). "
               "Legacy 2-col format `sku, on_hand` still accepted for back-compat "
               "but enables only Stock projection — **not** the per-store overstock view. "
               "Upload one file per country; reuploading one doesn't affect the others. "
               "Warehouse rows (store_code = 1) excluded automatically.")

    def _ingest_store_stock(uploaded, target_filename, country_label):
        df = pd.read_excel(uploaded) if uploaded.name.endswith(".xlsx") else pd.read_csv(uploaded)
        col_map = {}
        claimed = set()
        for c in df.columns:
            cl = str(c).strip().lower()
            target = None
            if cl in ("sku", "code", "šifra", "sifra") or "ifra" in cl:
                target = "sku"
            elif cl in ("on_hand", "zaliha", "stock", "stanje", "qty"):
                target = "on_hand"
            elif cl in ("store_code", "jedinica", "store_id", "store_number", "store_num"):
                target = "store_code"
            elif cl in ("store_name", "naziv_jedinice", "naziv", "store"):
                target = "store_name"
            if target and target not in claimed:
                col_map[c] = target
                claimed.add(target)
        df = df.rename(columns=col_map)
        df = df.loc[:, ~df.columns.duplicated()]
        if "sku" not in df.columns or "on_hand" not in df.columns:
            st.error(f"[{country_label}] Could not find sku / on_hand. Got: {list(df.columns)}")
            return

        has_store_cols = "store_code" in df.columns
        df["on_hand"] = pd.to_numeric(df["on_hand"], errors="coerce").fillna(0).astype(int)
        df = df.dropna(subset=["sku"])

        if has_store_cols:
            # ---- Long format ----
            df["store_code"] = pd.to_numeric(df["store_code"], errors="coerce").fillna(0).astype(int)
            if "store_name" not in df.columns:
                df["store_name"] = df["store_code"].astype(str)
            df["store_name"] = df["store_name"].fillna("").astype(str).str.strip()
            df = df[df["store_code"] != 1]    # exclude WH
            df = df[df["on_hand"] != 0]
            # If same (sku, store) appears twice (e.g. multiple location rows in one store), sum.
            # store_name kept from first occurrence within each (sku, store_code).
            df = (df.groupby(["sku", "store_code"], as_index=False)
                    .agg(store_name=("store_name", "first"), on_hand=("on_hand", "sum")))
            df = df[["sku", "store_code", "store_name", "on_hand"]]
            df.to_csv(DATA_DIR / target_filename, index=False)
            n_stores = df["store_code"].nunique()
            n_skus = df["sku"].nunique()
            st.success(f"[{country_label}] Saved {target_filename} — "
                       f"{len(df)} rows · {n_skus} SKUs × {n_stores} stores "
                       f"(long format · WH excluded)")
        else:
            # ---- Legacy 2-col format ----
            df = df[["sku", "on_hand"]]
            raw_rows = len(df)
            df = df.groupby("sku", as_index=False)["on_hand"].sum()
            df = df[df["on_hand"] != 0]
            df.to_csv(DATA_DIR / target_filename, index=False)
            rolled = raw_rows - len(df)
            msg = f"[{country_label}] Saved {target_filename} — {len(df)} SKUs (legacy aggregated format)"
            if rolled > 0:
                msg += f" · rolled up {rolled} duplicate rows"
            st.warning(msg + " · upload long format to enable Store overstock view.")
        clear_all_caches()
        st.rerun()

    for country, fname in STORE_STOCK_FILES:
        p = DATA_DIR / fname
        if p.exists():
            _df = pd.read_csv(p)
            if "store_code" in _df.columns:
                st.caption(f"✅ **{country}** — `{fname}` ({len(_df)} rows · "
                           f"{_df['sku'].nunique()} SKUs × "
                           f"{_df['store_code'].nunique()} stores · long format)")
            else:
                st.caption(f"✅ **{country}** — `{fname}` ({len(_df)} SKUs · legacy aggregated)")
        else:
            st.caption(f"⚪ **{country}** — not uploaded")
        up = st.file_uploader(
            f"{country} store stock ({fname})",
            type=["csv", "xlsx"],
            key=f"sup_up_stock_stores_{country.lower()}",
            label_visibility="collapsed",
        )
        if up is not None:
            _ingest_store_stock(up, fname, country)

    st.divider()
    st.subheader("3 · Incoming supply (open POs)")
    st.caption("Columns: `sku, year, week, qty` — purchase orders expected to arrive.")
    up = st.file_uploader("incoming_supply.csv or .xlsx", type=["csv", "xlsx"], key="sup_up_in")
    if up is not None:
        df = pd.read_excel(up) if up.name.endswith(".xlsx") else pd.read_csv(up)
        df.to_csv(DATA_DIR / "incoming_supply.csv", index=False)
        st.success(f"Saved incoming_supply.csv — {len(df)} rows")
        st.cache_data.clear()

    st.divider()
    st.subheader("4 · Supply master (MOQ + lead time)")
    st.caption("Columns: `sku, supplier, lead_time_weeks, moq` — merged with "
               "`sku_plan_list.csv` at runtime. Upload once, edit rarely.")
    up = st.file_uploader("supply_master.csv or .xlsx", type=["csv", "xlsx"], key="sup_up_mas")
    if up is not None:
        df = pd.read_excel(up) if up.name.endswith(".xlsx") else pd.read_csv(up)
        df.to_csv(DATA_DIR / "supply_master.csv", index=False)
        st.success(f"Saved supply_master.csv — {len(df)} SKUs")
        st.cache_data.clear()

    st.divider()
    st.subheader("5 · Forecast bridge from Demand")
    fsp = DATA_DIR / "forecast_for_supply.csv"
    if fsp.exists():
        st.success(f"✅ `forecast_for_supply.csv` — "
                   f"last refreshed {datetime.fromtimestamp(fsp.stat().st_mtime):%Y-%m-%d %H:%M}")
    else:
        st.warning("⚠️ `forecast_for_supply.csv` not yet generated")
    if st.button("🔄 Generate from current Demand Plan", use_container_width=True):
        src, err = write_forecast_for_supply()
        ts = datetime.now().strftime("%Y-%m-%d %H:%M")
        if err:
            st.error(f"GREŠKA: forecast_for_supply.csv nije zapisan. "
                     f"Supply modul koristi stare podatke! ({err})")
        elif src == "output_total":
            st.success(f"✅ Generated at {ts} — from **Demand Output - Total** sheet.")
            st.cache_data.clear()
        elif src == "reconstructed":
            st.success(f"✅ Generated at {ts} — reconstructed from Demand Planning blocks. "
                       "(Open the xlsx in Excel and save it to enable "
                       "direct reads from Demand Output - Total.)")
            st.cache_data.clear()
        else:
            st.error("No demand plan loaded — run the forecast first on the Demand side.")

    st.caption("💡 To edit MOQs for all SKUs at once, use the **MOQ** page.")


def supply_reference_editor(
    *,
    title: str,
    intro: str,
    storage_filename: str,
    value_cols: list[str],
    column_config: dict,
    upload_hint: str,
    key_prefix: str,
    extra_badges=None,   # optional callable(master, edited) -> list[str] of badges per row
):
    """Shared Upload + bulk-edit UX for a per-SKU supply reference table.

    Upload accepts an Excel/CSV with `sku` + any of `value_cols`; rows are
    upserted into `data/<storage_filename>` without disturbing other columns.
    The editor merges the storage file with `sku_plan_list.csv` for context
    (name / category / tier) and only allows edits to `value_cols`.
    """
    st.title(title)
    if intro:
        st.caption(intro)

    storage_path = DATA_DIR / storage_filename

    # ---- Upload ----
    st.subheader("Upload")
    st.caption(upload_hint)
    up = st.file_uploader(f"`sku` + {', '.join(value_cols)}",
                          type=["xlsx", "csv"], key=f"{key_prefix}_upload")
    if up is not None:
        try:
            new = pd.read_excel(up) if up.name.endswith(".xlsx") else pd.read_csv(up)
            # Normalise: lower, strip, spaces→underscores, drop non-alnum noise
            def _norm(c):
                s = str(c).strip().lower().replace(" ", "_")
                return "".join(ch for ch in s if ch.isalnum() or ch == "_")
            new.columns = [_norm(c) for c in new.columns]

            # Known Croatian / ERP aliases → canonical
            alias = {
                "sku": {"sifra", "ifra", "code", "artikl", "artikal"},
                "cost_price": {"nabavna", "nabavna_cijena", "costprice", "cost", "cijena"},
                "ruc": {"marza", "margin", "ruc_pct"},
                "moq": {"minimum_order_qty", "min_order", "min_qty"},
                "on_hand": {"zaliha", "stock", "stanje", "qty"},
            }
            for canonical, aliases in alias.items():
                if canonical in new.columns:
                    continue
                for a in aliases:
                    if a in new.columns:
                        new = new.rename(columns={a: canonical})
                        break

            missing = [c for c in ["sku", *value_cols] if c not in new.columns]
            if missing:
                st.error(f"Upload must contain these columns: {missing}. Found: {list(new.columns)}")
            else:
                new = new[["sku", *value_cols]].dropna(subset=["sku"])
                for c in value_cols:
                    new[c] = pd.to_numeric(new[c], errors="coerce").fillna(0)

                if storage_path.exists():
                    store = pd.read_csv(storage_path)
                else:
                    store = pd.DataFrame(columns=["sku", *value_cols])
                for c in value_cols:
                    if c not in store.columns:
                        store[c] = np.nan

                store = store.set_index("sku")
                for _, r in new.iterrows():
                    for c in value_cols:
                        store.loc[r["sku"], c] = r[c]
                store = store.reset_index()
                store.to_csv(storage_path, index=False)
                st.success(f"Upserted {len(new)} SKU(s) into `{storage_filename}`.")
                st.cache_data.clear()
        except Exception as e:
            st.error(f"Could not parse upload: {e}")

    st.divider()

    # ---- Table editor ----
    st.subheader("Edit")
    plan_path = DATA_DIR / "sku_plan_list.csv"
    if not plan_path.exists():
        st.warning("No SKU master data. Upload `sku_plan_list.csv` on the Demand side first.")
        return

    plan = pd.read_csv(plan_path)
    plan = plan.rename(columns={"cat": "category", "oznaka": "tier"})
    keep = [c for c in ["sku", "name", "category", "tier"] if c in plan.columns]
    master = plan[keep].copy()

    if storage_path.exists():
        store = pd.read_csv(storage_path)
        for c in value_cols:
            if c not in store.columns:
                store[c] = np.nan
        master = master.merge(store[["sku", *value_cols]], on="sku", how="left")
    else:
        for c in value_cols:
            master[c] = np.nan
    for c in value_cols:
        master[c] = master[c].fillna(0)

    cats = ["All"] + sorted([c for c in master.get("category", pd.Series()).dropna().unique() if c])
    c1, c2 = st.columns([1, 3])
    cat_sel = c1.selectbox("Category", cats, key=f"{key_prefix}_cat_filter")
    search = c2.text_input("Search SKU / name", key=f"{key_prefix}_search").strip().lower()

    view = master.copy()
    if cat_sel != "All":
        view = view[view["category"] == cat_sel]
    if search:
        mask = view["sku"].astype(str).str.lower().str.contains(search, na=False)
        if "name" in view.columns:
            mask |= view["name"].astype(str).str.lower().str.contains(search, na=False)
        view = view[mask]

    edit_cols = [c for c in ["sku", "name", "category", "tier"] if c in view.columns] + value_cols
    view = view[edit_cols].reset_index(drop=True)

    edited = st.data_editor(
        view,
        use_container_width=True,
        hide_index=True,
        disabled=[c for c in edit_cols if c not in value_cols],
        column_config=column_config,
        num_rows="fixed",
        key=f"{key_prefix}_editor",
    )

    if extra_badges is not None:
        msgs = extra_badges(master, edited)
        for m in msgs:
            st.caption(m)

    col_save, col_info = st.columns([1, 4])
    if col_save.button(f"💾 Save changes", use_container_width=True, type="primary",
                       key=f"{key_prefix}_save"):
        if storage_path.exists():
            store = pd.read_csv(storage_path)
        else:
            store = pd.DataFrame(columns=["sku", *value_cols])
        for c in value_cols:
            if c not in store.columns:
                store[c] = np.nan
        store = store.set_index("sku")
        changed = 0
        for _, r in edited.iterrows():
            row_changed = False
            for c in value_cols:
                new_val = float(r[c] or 0)
                existing = float(store.at[r["sku"], c]) if r["sku"] in store.index and pd.notna(store.at[r["sku"], c]) else None
                if existing is None or existing != new_val:
                    store.loc[r["sku"], c] = new_val
                    row_changed = True
            if row_changed:
                changed += 1
        store = store.reset_index()
        store.to_csv(storage_path, index=False)
        st.success(f"Saved — {changed} row(s) updated.")
        st.cache_data.clear()
    col_info.caption(f"{len(view)} row(s) shown. Filter by category or search to narrow down.")

    st.divider()
    with st.expander("⬇️ Download current table"):
        dl = master[["sku", *value_cols]].copy()
        if "name" in master.columns:
            dl.insert(1, "name", master["name"])
        if "category" in master.columns:
            dl.insert(2 if "name" in dl.columns else 1, "category", master["category"])

        c1, c2 = st.columns(2)
        c1.download_button(
            "Download as CSV",
            dl.to_csv(index=False).encode("utf-8"),
            file_name=storage_filename,
            mime="text/csv",
            key=f"{key_prefix}_dl",
        )
        # Excel version — useful for editing / cross-referencing in Excel
        from io import BytesIO
        xlsx_buf = BytesIO()
        with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer:
            dl.to_excel(writer, index=False, sheet_name="data")
        xlsx_name = storage_filename.rsplit(".", 1)[0] + ".xlsx"
        c2.download_button(
            "Download as Excel",
            xlsx_buf.getvalue(),
            file_name=xlsx_name,
            mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            key=f"{key_prefix}_dl_xlsx",
        )


def page_supply_moq():
    supply_reference_editor(
        title="📦 MOQ — minimum order quantities",
        intro="Upload an Excel with `sku, moq` columns, or edit the table below. "
              "Other columns in `supply_master.csv` (supplier, lead time) are preserved.",
        storage_filename="supply_master.csv",
        value_cols=["moq"],
        column_config={
            "moq": st.column_config.NumberColumn("MOQ", min_value=0, step=100, required=True),
        },
        upload_hint="Excel/CSV with two columns: `sku` and `moq`. "
                    "Existing MOQs are overwritten for SKUs in the upload; others are untouched.",
        key_prefix="moq",
    )


def _logistics_badges(master, edited):
    """Flag rows where pcs_per_pallet doesn't match tpkg * tpkgs_per_pallet."""
    msgs = []
    mismatches = 0
    for _, r in edited.iterrows():
        expected = float(r.get("pcs_per_transport_pkg", 0) or 0) * float(r.get("transport_pkgs_per_pallet", 0) or 0)
        actual = float(r.get("pcs_per_pallet", 0) or 0)
        if expected > 0 and actual > 0 and abs(expected - actual) > 0.5:
            mismatches += 1
    if mismatches:
        msgs.append(f"⚠️ {mismatches} row(s) have `pcs_per_pallet` ≠ `pcs_per_transport_pkg × transport_pkgs_per_pallet`. "
                    "This is allowed (partial / mixed pallets) but worth double-checking.")
    return msgs


def page_supply_logistics():
    supply_reference_editor(
        title="🚚 Logistics — pack & pallet sizes",
        intro="Per-SKU transport configuration: pieces per transport package, "
              "transport packages per pallet, pieces per pallet. Used by order entry "
              "to round quantities to full pallets.",
        storage_filename="sku_logistics.csv",
        value_cols=["pcs_per_transport_pkg", "transport_pkgs_per_pallet", "pcs_per_pallet"],
        column_config={
            "pcs_per_transport_pkg": st.column_config.NumberColumn(
                "Pcs / transport pkg", min_value=0, step=1),
            "transport_pkgs_per_pallet": st.column_config.NumberColumn(
                "Transport pkgs / pallet", min_value=0, step=1),
            "pcs_per_pallet": st.column_config.NumberColumn(
                "Pcs / pallet", min_value=0, step=1,
                help="Usually = pcs_per_transport_pkg × transport_pkgs_per_pallet, "
                     "but can differ for partial or mixed pallets."),
        },
        upload_hint="Excel/CSV with columns: `sku`, `pcs_per_transport_pkg`, "
                    "`transport_pkgs_per_pallet`, `pcs_per_pallet`. Rows in the upload "
                    "overwrite existing values; SKUs not in the upload are untouched.",
        key_prefix="logistics",
        extra_badges=_logistics_badges,
    )


_GRAMMAGE_RE = re.compile(
    r"(?:^|[\s\(\-])(\d+(?:[.,]\d+)?)\s*(kg|g|ml|l|oz|lbs?)\b",
    re.IGNORECASE,
)
_UNIT_TO_GRAMS = {
    "g": 1.0, "kg": 1000.0,
    "ml": 1.0, "l": 1000.0,
    "oz": 28.35, "lb": 453.59, "lbs": 453.59,
}
_STOPWORDS = {
    "the", "and", "for", "with", "without", "pulver", "pulverr",
    "powder", "tablete", "kapsule", "kaps", "tabs", "caps",
    "hr", "en", "de", "sl",
}


def _parse_grammage(name):
    """Return grams (or ml treated as grams) from a product name, or None.
    Picks the largest sensible value — some names have '500 g kapsula 60 pcs'
    where the weight is the meaningful one."""
    if not name or pd.isna(name):
        return None
    matches = _GRAMMAGE_RE.findall(str(name))
    if not matches:
        return None
    candidates = []
    for num, unit in matches:
        try:
            val = float(num.replace(",", "."))
        except ValueError:
            continue
        unit_l = unit.lower().rstrip("s")
        mult = _UNIT_TO_GRAMS.get(unit_l)
        if mult is None:
            continue
        grams = val * mult
        if 1 <= grams <= 20000:   # sanity filter
            candidates.append(grams)
    return max(candidates) if candidates else None


def _tokenize_name(name):
    """Lowercase tokens ≥3 chars, digits stripped, stopwords removed."""
    if not name or pd.isna(name):
        return set()
    raw = re.sub(r"[^\w\s]", " ", str(name).lower())
    raw = re.sub(r"\d+", " ", raw)
    return {t for t in raw.split() if len(t) >= 3 and t not in _STOPWORDS}


def impute_missing_costs():
    """Suggest cost_price for SKUs where it's missing or 0.
    Strategy (tries in order, keeps first match with ≥3 neighbours):
      1. Same sub_cat + grammage ±25%
      2. Same sub_cat + keyword overlap ≥2 tokens
      3. Same sub_cat — median €/g × target grams (if grammage known)
      4. Same cat — median €/g × target grams
      5. Same cat — plain median cost_price (grammage unknown)
    Returns DataFrame [sku, name, category, sub_cat, grams,
                       suggested_cost, method, neighbours, sample_skus]."""
    costs_path = DATA_DIR / "sku_costs.csv"
    if not costs_path.exists():
        return pd.DataFrame()
    costs = pd.read_csv(costs_path)

    # Build unified SKU master: name + cat + sub_cat from whichever map has it
    frames = []
    for fname, cols in [
        ("sku_subcat_map.csv", ["sku", "name", "sub_cat", "grup"]),
        ("sku_category_map.csv", ["sku", "name", "cat"]),
        ("sku_plan_list.csv", ["sku", "name", "cat"]),
    ]:
        p = DATA_DIR / fname
        if not p.exists():
            continue
        df = pd.read_csv(p)
        keep = [c for c in cols if c in df.columns]
        frames.append(df[keep])
    if not frames:
        return pd.DataFrame()
    master = frames[0]
    for f in frames[1:]:
        master = master.merge(f, on="sku", how="outer", suffixes=("", "_dup"))
        for c in list(master.columns):
            if c.endswith("_dup"):
                base = c[:-4]
                master[base] = master[base].where(
                    master[base].notna() & (master[base].astype(str) != ""),
                    master[c],
                )
                master = master.drop(columns=[c])
    for col in ["name", "sub_cat", "cat", "grup"]:
        if col not in master.columns:
            master[col] = ""
    master = master.drop_duplicates(subset="sku", keep="first")

    # Decorate with grammage + tokens
    master["grams"] = master["name"].apply(_parse_grammage)
    master["tokens"] = master["name"].apply(_tokenize_name)

    # Split known vs missing
    costs_pos = costs[costs["cost_price"].fillna(0) > 0][["sku", "cost_price"]]
    known = master.merge(costs_pos, on="sku", how="inner")
    known["per_gram"] = known.apply(
        lambda r: r["cost_price"] / r["grams"]
        if r.get("grams") and r["grams"] > 0 else None, axis=1,
    )

    missing_skus = set(master["sku"]) - set(costs_pos["sku"])
    missing = master[master["sku"].isin(missing_skus)].copy()

    suggestions = []
    for _, row in missing.iterrows():
        sku = row["sku"]
        name = row.get("name") or ""
        sub = row.get("sub_cat") or ""
        cat = row.get("cat") or ""
        g = row.get("grams")
        if g is not None and pd.isna(g):
            g = None
        tokens = row.get("tokens") or set()

        suggested, method, neighbours, samples = None, None, 0, []

        # 1. Same sub_cat + grammage ±25 %
        if sub and g:
            pool = known[(known["sub_cat"] == sub) &
                         known["grams"].between(g * 0.75, g * 1.25)]
            if len(pool) >= 3:
                suggested = float(pool["cost_price"].median())
                method = "sub_cat + grammage"
                neighbours = len(pool)
                samples = pool["sku"].head(5).tolist()

        # 2. Same sub_cat + keyword overlap ≥2
        if suggested is None and sub and tokens:
            pool = known[known["sub_cat"] == sub].copy()
            if len(pool):
                pool["overlap"] = pool["tokens"].apply(lambda t: len(t & tokens))
                pool = pool[pool["overlap"] >= 2]
                if len(pool) >= 3:
                    if g:
                        pg = pool["per_gram"].dropna()
                        if len(pg):
                            suggested = float(pg.median()) * g
                            method = "sub_cat + keywords (per-gram)"
                        else:
                            suggested = float(pool["cost_price"].median())
                            method = "sub_cat + keywords (flat)"
                    else:
                        suggested = float(pool["cost_price"].median())
                        method = "sub_cat + keywords (flat)"
                    neighbours = len(pool)
                    samples = pool.sort_values("overlap", ascending=False)["sku"].head(5).tolist()

        # 3. Same sub_cat — per-gram median
        if suggested is None and sub and g:
            pool = known[known["sub_cat"] == sub]
            pg = pool["per_gram"].dropna()
            if len(pg) >= 3:
                suggested = float(pg.median()) * g
                method = "sub_cat per-gram median"
                neighbours = len(pg)
                samples = pool["sku"].head(5).tolist()

        # 4. Same cat — per-gram
        if suggested is None and cat and g:
            pool = known[known["cat"] == cat]
            pg = pool["per_gram"].dropna()
            if len(pg) >= 3:
                suggested = float(pg.median()) * g
                method = "cat per-gram median"
                neighbours = len(pg)
                samples = pool["sku"].head(5).tolist()

        # 5. Same cat — flat median (grammage unknown, e.g. clothing)
        if suggested is None and cat:
            pool = known[known["cat"] == cat]
            if len(pool) >= 3:
                suggested = float(pool["cost_price"].median())
                method = "cat flat median"
                neighbours = len(pool)
                samples = pool["sku"].head(5).tolist()

        if suggested is None or suggested <= 0:
            continue

        suggestions.append({
            "sku": sku,
            "name": name,
            "category": cat,
            "sub_cat": sub,
            "grams": round(g, 1) if g else None,
            "suggested_cost": round(suggested, 2),
            "method": method,
            "neighbours": neighbours,
            "sample_skus": ", ".join(samples),
        })

    return pd.DataFrame(suggestions)


def page_supply_costs():
    supply_reference_editor(
        title="💶 Cost prices — nabavna cijena",
        intro="Per-SKU purchase cost (EUR) used to value overstock and cash-frozen capital. "
              "RUC (margin in EUR) is stored alongside for reference. "
              "Upload columns must already be renamed to `sku`, `cost_price`, `ruc` "
              "(original `Nabavna` → `cost_price`).",
        storage_filename="sku_costs.csv",
        value_cols=["cost_price", "ruc"],
        column_config={
            "cost_price": st.column_config.NumberColumn(
                "Cost (EUR)", min_value=0.0, step=0.01, format="%.2f"),
            "ruc": st.column_config.NumberColumn(
                "RUC (EUR)", min_value=0.0, step=0.01, format="%.2f"),
        },
        upload_hint="Excel/CSV with columns: `sku`, `cost_price`, `ruc`. "
                    "Rows overwrite existing values; SKUs not in the upload are untouched.",
        key_prefix="costs",
    )

    st.divider()
    st.subheader("🔎 Impute missing prices")
    st.caption(
        "Scans for SKUs with cost_price = 0 or missing and suggests a price based on "
        "similar articles (sub-category, grammage, keywords). Review below before applying."
    )

    if st.button("Run analysis", key="costs_impute_run"):
        with st.spinner("Analysing similar articles…"):
            st.session_state["_costs_impute_df"] = impute_missing_costs()

    sug = st.session_state.get("_costs_impute_df")
    if sug is None:
        return
    if len(sug) == 0:
        st.success("No missing prices found — or no similar articles available to impute from.")
        return

    st.write(f"**{len(sug)} SKUs** with suggested prices:")
    by_method = sug.groupby("method").size().to_dict()
    st.caption("By method: " + " · ".join(f"{m}: {n}" for m, n in by_method.items()))

    st.dataframe(sug, use_container_width=True, height=360)

    col1, col2 = st.columns(2)
    col1.download_button(
        "📥 Download suggestions (Excel)",
        data=_df_to_excel_bytes(sug, "imputed_prices"),
        file_name="imputed_prices.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        key="costs_impute_xlsx",
    )
    if col2.button("✅ Apply suggestions to sku_costs.csv", key="costs_impute_apply"):
        costs_path = DATA_DIR / "sku_costs.csv"
        existing = pd.read_csv(costs_path) if costs_path.exists() else pd.DataFrame(
            columns=["sku", "cost_price", "ruc"])
        existing.set_index("sku", inplace=True)
        applied = 0
        for _, r in sug.iterrows():
            sku = r["sku"]
            existing.loc[sku, "cost_price"] = r["suggested_cost"]
            if "ruc" not in existing.columns or pd.isna(existing.loc[sku, "ruc"]):
                existing.loc[sku, "ruc"] = 0.0
            applied += 1
        existing.reset_index().to_csv(costs_path, index=False)
        st.success(f"Applied {applied} imputed prices. Reload the page to verify.")
        clear_all_caches()
        st.session_state.pop("_costs_impute_df", None)


def _df_to_excel_bytes(df, sheet_name="Sheet1"):
    import io
    buf = io.BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as writer:
        df.to_excel(writer, sheet_name=sheet_name, index=False)
    return buf.getvalue()


def _model_only_fa_per_sku():
    """Per-SKU forecast accuracy for Inventory health.

    Reuses the 'model_only' FA view (planner-owned channels stripped).
    Returns {sku: {"fa": float, "n_weeks": int}} with fa ∈ [0,∞) before clipping.
    """
    try:
        sc = load_sales_data()
    except Exception:
        sc = None
    if sc is None:
        return {}
    fa_df = _build_fa_dataset("model_only", sc)
    if fa_df is None or len(fa_df) == 0:
        return {}
    per_sku = {}
    for sku, grp in fa_df.groupby("sku"):
        act = float(grp["actual"].sum())
        err = float(grp["error"].sum())
        if act <= 0:
            continue
        per_sku[sku] = {
            "fa": max(0.0, 1.0 - err / act),
            "n_weeks": int(len(grp)),
        }
    return per_sku


@st.cache_data(ttl=60)
def _load_inventory_inputs():
    """Gather every file Inventory health needs. Returns dict of DataFrames/dicts."""
    out = {}
    for name in ("stock.csv", "supply_master.csv", "incoming_supply.csv",
                 "forecast_for_supply.csv", "sku_costs.csv", "sku_prices.csv",
                 "sku_plan_list.csv", "sku_logistics.csv"):
        p = DATA_DIR / name
        if p.exists():
            df = pd.read_csv(p)
            # Tolerate UPPERCASE / MixedCase headers from ERP exports.
            df.columns = [str(c).strip().lower() for c in df.columns]
            out[name] = df
        else:
            out[name] = None
    if out["supply_master.csv"] is not None and "supplier" in out["supply_master.csv"].columns:
        out["supply_master.csv"]["supplier"] = out["supply_master.csv"]["supplier"].map(remap_supplier)
    return out


def page_supply_inventory_health():
    st.title("🏥 Inventory health")
    st.caption("Cashflow snapshot. Uses **on-hand only** against the **demand plan** "
               "(baseline + VP on-top + VP regular + MP on-top + MP regular, per-week). "
               "Overstock = cover > `1.5 × lead_time + safety_weeks`. "
               "Stockout risk = on-hand below safety buffer. "
               "Incoming is shown for reference but does not affect the flag.")

    data = _load_inventory_inputs()
    plan   = data["sku_plan_list.csv"]
    stock  = data["stock.csv"]
    master = data["supply_master.csv"]
    incoming = data["incoming_supply.csv"]
    fc     = data["forecast_for_supply.csv"]
    costs  = data["sku_costs.csv"]
    sell   = data["sku_prices.csv"]

    if plan is None or stock is None or master is None or fc is None:
        missing = [n for n in ("sku_plan_list.csv", "stock.csv", "supply_master.csv",
                               "forecast_for_supply.csv") if data[n] is None]
        st.warning(f"Missing required files: {', '.join(missing)}. Upload them on the Supply → Upload page first.")
        return

    using_cost = costs is not None
    if not using_cost:
        st.warning("⚠️ `sku_costs.csv` not found — valuing overstock with **sell price** "
                   "(overstates cash impact). Upload cost prices on 💶 Cost prices page for accurate numbers.")

    # ---- Controls ----
    c1, c2, c3 = st.columns(3)
    service_gold   = c1.slider("Service level — Gold",   80, 99, 98, key="sl_gold")   / 100
    service_silver = c2.slider("Service level — Silver", 80, 99, 95, key="sl_silver") / 100
    service_bronze = c3.slider("Service level — Bronze", 80, 99, 92, key="sl_bronze") / 100

    # Z for one-sided normal
    from math import sqrt
    try:
        from scipy.stats import norm
        z_from_sl = lambda sl: float(norm.ppf(sl))
    except Exception:
        # Lookup table fallback — scipy should be present (it's in requirements).
        _tbl = {0.80:0.84, 0.85:1.04, 0.90:1.28, 0.93:1.48, 0.95:1.65, 0.97:1.88, 0.99:2.33}
        z_from_sl = lambda sl: _tbl.get(round(sl, 2), 1.65)

    tier_z = {
        "01 GOLD":   z_from_sl(service_gold),
        "02 SILVER": z_from_sl(service_silver),
        "03 BRONZE": z_from_sl(service_bronze),
    }

    # ---- FA-aware safety stock (phase 2) ----
    with st.expander("🎯 Forecast-accuracy assumption (safety-stock driver)", expanded=False):
        st.caption(
            "Safety stock formula: `Z × √LT × √(σ² + (avg × (1 − FA))²)`. "
            "Per-SKU measured FA (Model-only, planner channels stripped) is used "
            "when enough history exists; otherwise falls back to global assumption. "
            "FA is clipped to [30%, 95%] to keep a single outlier week from dominating."
        )
        fc1, fc2 = st.columns(2)
        fa_min_weeks = fc1.slider(
            "Weeks of history needed to trust per-SKU FA",
            2, 12, 6, key="ih_fa_min_weeks",
            help="Below this, the SKU uses the global fallback instead of its own noisy measurement.",
        )
        fa_global = fc2.slider(
            "Global fallback FA (%) — used when SKU has less than threshold weeks",
            30, 95, 70, key="ih_fa_global",
            help="Your operational target. 70% matches the 1–2 month target communicated to the board.",
        ) / 100.0

    # Precompute per-SKU FA map
    per_sku_fa = _model_only_fa_per_sku()

    def resolve_fa(sku):
        """Return (fa_clipped, source). source ∈ {'measured','global'}."""
        info = per_sku_fa.get(sku)
        if info and info["n_weeks"] >= fa_min_weeks:
            return max(0.30, min(0.95, info["fa"])), "measured"
        return fa_global, "global"

    # ---- Build per-SKU metrics ----
    plan_idx = (plan.drop_duplicates(subset=["sku"], keep="last").set_index("sku")
                if "sku" in plan.columns else plan)
    stock_map = dict(zip(stock["sku"], stock["on_hand"]))
    master_idx = master.drop_duplicates(subset=["sku"], keep="last").set_index("sku")
    cost_map = dict(zip(costs["sku"], costs["cost_price"])) if using_cost else {}
    sell_map = dict(zip(sell["sku"], sell["avg_sell_price"])) if sell is not None else {}

    # Incoming sum per SKU (all future weeks in the file).
    inc_map = {}
    if incoming is not None and len(incoming) > 0:
        inc_map = incoming.groupby("sku")["qty"].sum().to_dict()

    # Forecast: handle both long-form (sku, year, week, demand) and wide (CW-columns).
    # Build per-SKU sorted weekly demand arrays so we can sum over the required window
    # rather than relying on a flat average (promo peaks would otherwise get smoothed out).
    fc_cols = [c for c in fc.columns if str(c).startswith("CW")]
    fc_series = {}   # sku -> np.array of demand per week, chronological
    if fc_cols:
        for _, r in fc.iterrows():
            fc_series[r["sku"]] = np.array([float(r[c] or 0) for c in fc_cols], dtype=float)
    elif {"sku", "year", "week", "demand"}.issubset(fc.columns):
        for sku, grp in fc.sort_values(["year", "week"]).groupby("sku"):
            fc_series[sku] = grp["demand"].to_numpy(dtype=float)
    elif "total_fc" in fc.columns:
        # Legacy flat format: fall back to flat average over 13 weeks.
        for _, r in fc.iterrows():
            fc_series[r["sku"]] = np.full(13, float(r["total_fc"]) / 13.0)
    else:
        st.error("forecast_for_supply.csv format not recognised — need either CW columns, "
                 "(sku, year, week, demand), or `total_fc`.")
        return

    from math import ceil

    def demand_over(weeks_needed: float, series: np.ndarray) -> float:
        """Sum demand over the first `weeks_needed` weeks.
        Partial trailing week is prorated. If horizon exceeds the forecast,
        extrapolates with the horizon's average."""
        if len(series) == 0 or weeks_needed <= 0:
            return 0.0
        whole = int(weeks_needed)
        frac = weeks_needed - whole
        available = len(series)
        if whole >= available:
            avg = series.mean() if available > 0 else 0.0
            return float(series.sum() + (weeks_needed - available) * avg)
        total = float(series[:whole].sum())
        if frac > 0:
            total += frac * float(series[whole])
        return total

    rows = []
    for sku, series in fc_series.items():
        fc_total = float(series.sum())
        n_weeks = int(len(series))
        on_hand = float(stock_map.get(sku, 0) or 0)
        incoming_qty = float(inc_map.get(sku, 0) or 0)

        if sku in master_idx.index:
            lt = float(master_idx.at[sku, "lead_time_weeks"] or 0)
            moq = float(master_idx.at[sku, "moq"] or 0)
            supplier = str(master_idx.at[sku, "supplier"] or "")
        else:
            lt, moq, supplier = 0.0, 0.0, ""

        if sku in plan_idx.index:
            tier = str(plan_idx.at[sku, "oznaka"]) if "oznaka" in plan_idx.columns else ""
            cat  = str(plan_idx.at[sku, "cat"])    if "cat" in plan_idx.columns    else ""
            name = str(plan_idx.at[sku, "name"])   if "name" in plan_idx.columns   else ""
            cv   = float(plan_idx.at[sku, "total_cv"]) if "total_cv" in plan_idx.columns and pd.notna(plan_idx.at[sku, "total_cv"]) else 0.5
        else:
            tier, cat, name, cv = "", "", "", 0.5

        avg_weekly = fc_total / n_weeks if n_weeks else 0
        sigma_weekly = avg_weekly * cv
        z = tier_z.get(tier, z_from_sl(0.93))
        fa_used, fa_source = resolve_fa(sku)
        # SS = Z × √LT × √(σ² + (avg × (1 − FA))²)
        forecast_err_sigma = avg_weekly * (1.0 - fa_used)
        combined_sigma = sqrt(sigma_weekly ** 2 + forecast_err_sigma ** 2)
        safety_stock = z * combined_sigma * (sqrt(lt) if lt > 0 else 0)

        # Cashflow snapshot using ACTUAL per-week demand from the plan
        # (includes baseline + VP on-top + VP regular + MP on-top + MP regular).
        # Overstock      → on_hand > demand over 1.5× lead_time + safety_weeks.
        # Stockout risk  → on_hand already below the safety buffer.
        #                  (Lead-time coverage ignored — some incoming POs may not
        #                  yet be captured in incoming_supply.csv, so we don't
        #                  want to false-flag SKUs that actually have orders in flight.)
        # Balanced       → in between.
        safety_weeks = (safety_stock / avg_weekly) if avg_weekly > 0 else 0
        max_cover_needed = 1.5 * lt + safety_weeks
        max_on_hand = demand_over(max_cover_needed, series)

        cover_now = (on_hand / avg_weekly) if avg_weekly > 0 else (float('inf') if on_hand > 0 else 0)

        excess_units = max(0.0, on_hand - max_on_hand)
        shortage_units = max(0.0, safety_stock - on_hand)

        unit_value = float(cost_map.get(sku, 0) or 0)
        valued_with = "cost"
        if unit_value == 0 and sell_map:
            unit_value = float(sell_map.get(sku, 0) or 0)
            valued_with = "sell"

        if excess_units > 0:
            status = "overstock"
        elif shortage_units > 0:
            status = "stockout_risk"
        else:
            status = "balanced"
        excess_eur = excess_units * unit_value

        rows.append({
            "sku": sku,
            "name": name,
            "category": cat,
            "tier": tier,
            "supplier": supplier,
            "on_hand": int(on_hand),
            "incoming": int(incoming_qty),
            "avg_weekly_fc": round(avg_weekly, 1),
            "cv": round(cv, 2),
            "lt_weeks": lt,
            "moq": int(moq),
            "safety_stock": int(round(safety_stock)),
            "safety_weeks": round(safety_weeks, 1),
            "fa_used": round(fa_used * 100, 1),
            "fa_source": fa_source,
            "max_cover_needed": round(max_cover_needed, 1),
            "excess_units": int(round(excess_units)),
            "excess_eur": round(excess_eur, 2),
            "shortage_units": int(round(shortage_units)),
            "cover_now": round(cover_now, 1) if cover_now != float('inf') else None,
            "status": status,
            "_valued_with": valued_with,
        })

    df = pd.DataFrame(rows)
    if df.empty:
        st.info("No SKUs with forecast data — run the forecast first.")
        return

    # ---- KPIs ----
    k1, k2, k3, k4 = st.columns(4)
    total_overstock_eur = df["excess_eur"].sum()
    n_overstock = int((df["status"] == "overstock").sum())
    n_short = int((df["status"] == "stockout_risk").sum())
    n_balanced = int((df["status"] == "balanced").sum())

    unit_label = "nabavna" if using_cost else "⚠️ sell (proxy)"
    k1.metric(f"€ frozen in overstock ({unit_label})", f"€{total_overstock_eur:,.0f}")
    k2.metric("Overstock SKUs", n_overstock)
    k3.metric("Stockout-risk SKUs", n_short)
    k4.metric("Balanced SKUs", n_balanced)

    # ---- Filters ----
    st.divider()
    f1, f2, f3, f4 = st.columns(4)
    tier_opts = ["All"] + sorted(df["tier"].dropna().unique().tolist())
    cat_opts  = ["All"] + sorted([c for c in df["category"].dropna().unique() if c])
    sup_opts  = ["All"] + sorted([s for s in df["supplier"].dropna().unique() if s])
    status_opts = ["All", "overstock", "stockout_risk", "balanced"]

    tier_sel   = f1.selectbox("Tier", tier_opts, key="ih_tier")
    cat_sel    = f2.selectbox("Category", cat_opts, key="ih_cat")
    sup_sel    = f3.selectbox("Supplier", sup_opts, key="ih_sup")
    status_sel = f4.selectbox("Status", status_opts, index=1, key="ih_status")

    view = df.copy()
    if tier_sel != "All":   view = view[view["tier"] == tier_sel]
    if cat_sel != "All":    view = view[view["category"] == cat_sel]
    if sup_sel != "All":    view = view[view["supplier"] == sup_sel]
    if status_sel != "All": view = view[view["status"] == status_sel]

    view = view.sort_values("excess_eur", ascending=False)

    # ---- Main table ----
    n_measured = int((df["fa_source"] == "measured").sum())
    n_total = len(df)
    st.subheader(f"{len(view)} SKUs")
    st.caption(f"FA source: **{n_measured} / {n_total}** SKUs using per-SKU measured FA "
               f"(≥ {fa_min_weeks} weeks of history). "
               f"Rest fall back to global assumption of {int(fa_global*100)}%.")
    display_cols = ["sku", "name", "tier", "category", "supplier",
                    "on_hand", "incoming", "avg_weekly_fc", "cv",
                    "lt_weeks", "fa_used", "fa_source",
                    "safety_weeks", "max_cover_needed",
                    "cover_now",
                    "excess_units", "excess_eur", "status"]
    st.dataframe(
        view[display_cols], use_container_width=True, hide_index=True,
        column_config={
            "excess_eur":       st.column_config.NumberColumn("Excess €", format="€%.0f"),
            "cover_now":        st.column_config.NumberColumn("Cover now (wks)", format="%.1f",
                                    help="on_hand / avg_weekly demand plan — drives the status flag"),
            "avg_weekly_fc":    st.column_config.NumberColumn("Avg wk demand", format="%.1f"),
            "fa_used":          st.column_config.NumberColumn("FA used (%)", format="%.1f",
                                    help="Forecast accuracy applied in safety-stock formula, clipped to 30–95%"),
            "fa_source":        st.column_config.TextColumn("FA source",
                                    help="'measured' = per-SKU from Model-only FA; 'global' = fallback"),
            "safety_weeks":     st.column_config.NumberColumn("Safety (wks)", format="%.1f"),
            "max_cover_needed": st.column_config.NumberColumn("Max cover (wks)", format="%.1f",
                                    help="1.5 × lead_time + safety — cover above this = overstock"),
        },
    )

    # ---- Supplier rollup ----
    st.divider()
    st.subheader("Overstock € by supplier")
    sup_roll = (df[df["status"] == "overstock"]
                .groupby("supplier", dropna=False)
                .agg(skus=("sku", "count"),
                     excess_units=("excess_units", "sum"),
                     excess_eur=("excess_eur", "sum"))
                .sort_values("excess_eur", ascending=False)
                .reset_index())
    if not sup_roll.empty:
        st.dataframe(
            sup_roll, use_container_width=True, hide_index=True,
            column_config={"excess_eur": st.column_config.NumberColumn("Excess €", format="€%.0f")},
        )
    else:
        st.caption("No overstock.")

    # ---- Download (XLSX) ----
    st.divider()
    from io import BytesIO
    buf = BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as xlw:
        view[display_cols].to_excel(xlw, sheet_name="Inventory health", index=False)
        if not sup_roll.empty:
            sup_roll.to_excel(xlw, sheet_name="By supplier", index=False)
    st.download_button(
        "⬇️ Download inventory health (XLSX)",
        buf.getvalue(),
        file_name="inventory_health.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )


def page_supply_store_overstock():
    """Per-store HR overstock analysis for Gold/Silver/Bronze SKUs.

    Optimal stock = safety_stock + cycle_stock, with LT = 1 week.
    Avg & sigma computed from sales_detailed.csv (HR + RCM only) EXCLUDING
    weeks flagged in erp_promo_calendar.csv. SKU-store pairs with <4 weeks
    of non-promo history are flagged as 'insufficient history' and excluded
    from the overstock totals (but still listed so user can investigate).
    """
    st.title("🏪 Store overstock — HR")
    st.caption("Per-store overstock for tiered SKUs (Gold/Silver/Bronze) only. "
               "Optimal = safety stock + cycle stock, LT = 1 week. "
               "Avg / σ computed from retail POS sales (RCM docs) **excluding ERP promo weeks** "
               "to avoid promo-inflated baselines.")

    # ---- Load inputs ----
    sales_p = DATA_DIR / "sales_detailed.csv"
    promo_p = DATA_DIR / "erp_promo_calendar.csv"
    plan_p = DATA_DIR / "sku_plan_list.csv"
    costs_p = DATA_DIR / "sku_costs.csv"
    prices_p = DATA_DIR / "sku_prices.csv"

    stock_ps = sup_load_stock_stores_long(country="HR")
    missing = []
    if stock_ps is None or len(stock_ps) == 0:
        missing.append("`stock_stores.csv` for HR in **long format** "
                       "(`sku, store_code, store_name, on_hand`) — Supply → Upload data → section 2")
    if not sales_p.exists(): missing.append("sales_detailed.csv")
    if not plan_p.exists(): missing.append("sku_plan_list.csv")
    if missing:
        st.warning("Missing required files:")
        for m in missing:
            st.markdown(f"- {m}")
        return

    sales = pd.read_csv(sales_p, low_memory=False)
    plan = pd.read_csv(plan_p)
    plan.columns = [c.lower() for c in plan.columns]
    plan = plan.rename(columns={"cat": "category", "oznaka": "tier"})

    promo = pd.read_csv(promo_p) if promo_p.exists() else None
    costs = pd.read_csv(costs_p) if costs_p.exists() else None
    prices = pd.read_csv(prices_p) if prices_p.exists() else None

    cost_map = dict(zip(costs["sku"], costs["cost_price"])) if costs is not None else {}
    price_map = dict(zip(prices["sku"], prices["avg_sell_price"])) if prices is not None else {}

    # ---- Controls ----
    c1, c2, c3, c4 = st.columns([2, 1, 1, 1])
    tier_sel = c1.multiselect(
        "Tier filter",
        ["01 GOLD", "02 SILVER", "03 BRONZE"],
        default=["01 GOLD", "02 SILVER", "03 BRONZE"],
        key="store_ov_tier",
    )
    service_gold = c2.slider("SL Gold", 80, 99, 98, key="store_ov_sl_gold") / 100
    service_silver = c3.slider("SL Silver", 80, 99, 95, key="store_ov_sl_silver") / 100
    service_bronze = c4.slider("SL Bronze", 80, 99, 92, key="store_ov_sl_bronze") / 100

    try:
        from scipy.stats import norm
        z_of = lambda sl: float(norm.ppf(sl))
    except Exception:
        _tbl = {0.80: 0.84, 0.85: 1.04, 0.90: 1.28, 0.92: 1.41, 0.95: 1.65, 0.98: 2.05, 0.99: 2.33}
        z_of = lambda sl: _tbl.get(round(sl, 2), 1.65)
    tier_z = {"01 GOLD": z_of(service_gold), "02 SILVER": z_of(service_silver),
              "03 BRONZE": z_of(service_bronze)}

    if not tier_sel:
        st.info("Select at least one tier.")
        return

    # ---- Filter inputs ----
    tiered_skus = set(plan[plan["tier"].isin(tier_sel)]["sku"])
    if not tiered_skus:
        st.info("No SKUs match the tier filter.")
        return

    stock_ps = stock_ps[stock_ps["sku"].isin(tiered_skus)].copy()
    if len(stock_ps) == 0:
        st.info("No store-stock rows match the tier filter.")
        return

    # Sales: HR + RCM only (retail POS receipts)
    sales_hr = sales[(sales["drzava"] == "HR") & (sales["tip_dok"] == "RCM")].copy()
    sales_hr = sales_hr[sales_hr["sku"].isin(tiered_skus)]
    if "jedinica" not in sales_hr.columns:
        st.error("sales_detailed.csv missing `jedinica` column.")
        return
    sales_hr["jedinica"] = pd.to_numeric(sales_hr["jedinica"], errors="coerce").fillna(0).astype(int)
    sales_hr = sales_hr[sales_hr["jedinica"] != 1]  # exclude warehouse
    sales_hr["kolicina"] = pd.to_numeric(sales_hr["kolicina"], errors="coerce").fillna(0)
    sales_hr["year"] = pd.to_numeric(sales_hr["year"], errors="coerce").fillna(0).astype(int)
    sales_hr["week"] = pd.to_numeric(sales_hr["week"], errors="coerce").fillna(0).astype(int)

    # Promo exclusion: drop (sku, year, week) rows that have is_erp_promo=1
    if promo is not None and len(promo):
        promo_keys = set(zip(
            promo[promo["is_erp_promo"] == 1]["sku"],
            promo[promo["is_erp_promo"] == 1]["year"],
            promo[promo["is_erp_promo"] == 1]["week"],
        ))
        sales_hr["_key"] = list(zip(sales_hr["sku"], sales_hr["year"], sales_hr["week"]))
        n_before = len(sales_hr)
        sales_hr = sales_hr[~sales_hr["_key"].isin(promo_keys)].drop(columns=["_key"])
        promo_rows_dropped = n_before - len(sales_hr)
    else:
        promo_rows_dropped = 0

    # Aggregate sales to (sku, store, year, week)
    weekly = sales_hr.groupby(["sku", "jedinica", "year", "week"], as_index=False)["kolicina"].sum()
    weekly = weekly.rename(columns={"jedinica": "store_code", "kolicina": "qty"})

    # Per (sku, store): avg, sigma, n_weeks
    stats = weekly.groupby(["sku", "store_code"]).agg(
        avg_weekly=("qty", "mean"),
        sigma=("qty", "std"),
        weeks_with_sales=("qty", "size"),
    ).reset_index()
    stats["sigma"] = stats["sigma"].fillna(0)

    # Merge with on-hand (store_name comes from stock_ps, the uploaded file)
    merged = stock_ps.merge(stats, on=["sku", "store_code"], how="left")
    merged["avg_weekly"] = merged["avg_weekly"].fillna(0)
    merged["sigma"] = merged["sigma"].fillna(0)
    merged["weeks_with_sales"] = merged["weeks_with_sales"].fillna(0).astype(int)
    merged["store_name"] = merged["store_name"].fillna(merged["store_code"].astype(str))

    # Attach tier / category
    plan_min = plan[["sku", "tier", "category", "name"]].drop_duplicates("sku") if "name" in plan.columns else plan[["sku", "tier", "category"]].drop_duplicates("sku")
    merged = merged.merge(plan_min, on="sku", how="left")
    if "name" not in merged.columns:
        merged["name"] = ""
    merged["name"] = merged["name"].fillna("")

    # Optimal stock per row
    LT = 1
    def _optimal(row):
        z = tier_z.get(row["tier"], z_of(service_silver))
        ss = z * row["sigma"] * (LT ** 0.5)
        cycle = LT * row["avg_weekly"]
        return ss + cycle
    merged["safety_stock"] = merged.apply(
        lambda r: tier_z.get(r["tier"], 1.65) * r["sigma"] * (LT ** 0.5), axis=1)
    merged["cycle_stock"] = LT * merged["avg_weekly"]
    merged["optimal_stock"] = merged["safety_stock"] + merged["cycle_stock"]

    # Flag insufficient history
    MIN_WEEKS = 4
    merged["insufficient_history"] = merged["weeks_with_sales"] < MIN_WEEKS

    # Overstock
    merged["overstock_units"] = (merged["on_hand"] - merged["optimal_stock"]).clip(lower=0)
    # Insufficient-history rows: don't claim overstock (we don't know the baseline)
    merged.loc[merged["insufficient_history"], "overstock_units"] = 0

    merged["cost_price"] = merged["sku"].map(cost_map).fillna(0)
    merged["sell_price"] = merged["sku"].map(price_map).fillna(0)
    merged["overstock_eur_cost"] = merged["overstock_units"] * merged["cost_price"]
    merged["overstock_eur_sell"] = merged["overstock_units"] * merged["sell_price"]

    # ---- KPI strip ----
    over = merged[merged["overstock_units"] > 0]
    total_units = int(over["overstock_units"].sum())
    total_cost = float(over["overstock_eur_cost"].sum())
    total_sell = float(over["overstock_eur_sell"].sum())

    k = st.columns(5)
    k[0].metric("Overstock SKU×store pairs", f"{len(over):,}")
    k[0].caption(f"of {len(merged):,} total")
    k[1].metric("Overstock units", f"{total_units:,}")
    k[2].metric("Overstock € (cost)", f"€{total_cost:,.0f}")
    k[3].metric("Overstock € (sell)", f"€{total_sell:,.0f}")
    insuff = int(merged["insufficient_history"].sum())
    k[4].metric("Insufficient history", f"{insuff}")
    k[4].caption(f"<{MIN_WEEKS}w of non-promo sales")

    caption_bits = [f"Stores: **{merged['store_code'].nunique()}**",
                    f"SKUs: **{merged['sku'].nunique()}**",
                    f"Weeks of history available: **CW{int(weekly['week'].min())}–CW{int(weekly['week'].max())}** "
                    f"({weekly[['year', 'week']].drop_duplicates().shape[0]} weeks)"]
    if promo_rows_dropped > 0:
        caption_bits.append(f"Promo rows dropped: **{promo_rows_dropped:,}**")
    st.caption(" · ".join(caption_bits))

    # ---- Aggregation tabs ----
    t1, t2, t3, t4 = st.tabs(["By tier", "By category", "By store", "Detail (SKU × store)"])

    with t1:
        agg = over.groupby("tier").agg(
            pairs=("sku", "size"),
            units=("overstock_units", "sum"),
            eur_cost=("overstock_eur_cost", "sum"),
            eur_sell=("overstock_eur_sell", "sum"),
        ).round(0).sort_values("eur_cost", ascending=False)
        agg.columns = ["Pairs", "Units", "€ cost", "€ sell"]
        st.dataframe(agg, use_container_width=True)

    with t2:
        agg = over.groupby("category").agg(
            pairs=("sku", "size"),
            units=("overstock_units", "sum"),
            eur_cost=("overstock_eur_cost", "sum"),
            eur_sell=("overstock_eur_sell", "sum"),
        ).round(0).sort_values("eur_cost", ascending=False)
        agg.columns = ["Pairs", "Units", "€ cost", "€ sell"]
        st.dataframe(agg, use_container_width=True)

    with t3:
        agg = over.groupby(["store_code", "store_name"]).agg(
            pairs=("sku", "size"),
            units=("overstock_units", "sum"),
            eur_cost=("overstock_eur_cost", "sum"),
            eur_sell=("overstock_eur_sell", "sum"),
        ).round(0).sort_values("eur_cost", ascending=False)
        agg.columns = ["Pairs", "Units", "€ cost", "€ sell"]
        st.dataframe(agg, use_container_width=True)

    with t4:
        show_insuf = st.checkbox("Show insufficient-history rows", value=False, key="store_ov_show_insuf")
        det = merged.copy()
        if not show_insuf:
            det = det[~det["insufficient_history"]]
        det = det.sort_values("overstock_eur_cost", ascending=False)
        cols = ["sku", "name", "tier", "category", "store_code", "store_name",
                "on_hand", "avg_weekly", "sigma", "weeks_with_sales",
                "safety_stock", "cycle_stock", "optimal_stock",
                "overstock_units", "cost_price", "sell_price",
                "overstock_eur_cost", "overstock_eur_sell", "insufficient_history"]
        det_display = det[cols].rename(columns={
            "name": "Name", "store_code": "Store #", "store_name": "Store",
            "on_hand": "On hand", "avg_weekly": "Avg/wk", "sigma": "σ",
            "weeks_with_sales": "Weeks (non-promo)",
            "safety_stock": "Safety stock", "cycle_stock": "Cycle stock",
            "optimal_stock": "Optimal", "overstock_units": "Overstock units",
            "cost_price": "Cost €", "sell_price": "Sell €",
            "overstock_eur_cost": "Overstock € cost",
            "overstock_eur_sell": "Overstock € sell",
            "insufficient_history": "Insuff. history",
        })
        for c in ["Avg/wk", "σ", "Safety stock", "Cycle stock", "Optimal"]:
            det_display[c] = det_display[c].round(1)
        for c in ["Cost €", "Sell €", "Overstock € cost", "Overstock € sell"]:
            det_display[c] = det_display[c].round(0)
        st.dataframe(det_display, use_container_width=True, height=500, hide_index=True)

    # ---- Excel download ----
    from io import BytesIO
    buf = BytesIO()
    with pd.ExcelWriter(buf, engine="openpyxl") as xw:
        merged.to_excel(xw, sheet_name="detail", index=False)
        if len(over):
            over.groupby("tier").agg(
                pairs=("sku", "size"), units=("overstock_units", "sum"),
                eur_cost=("overstock_eur_cost", "sum"), eur_sell=("overstock_eur_sell", "sum"),
            ).reset_index().to_excel(xw, sheet_name="by_tier", index=False)
            over.groupby(["store_code", "store_name"]).agg(
                pairs=("sku", "size"), units=("overstock_units", "sum"),
                eur_cost=("overstock_eur_cost", "sum"), eur_sell=("overstock_eur_sell", "sum"),
            ).reset_index().to_excel(xw, sheet_name="by_store", index=False)
    ts = datetime.now().strftime("%Y%m%d_%H%M")
    st.download_button(
        "⬇️ Download store overstock (XLSX)",
        buf.getvalue(),
        file_name=f"store_overstock_hr_{ts}.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )

    with st.expander("Methodology & assumptions"):
        st.markdown(f"""
- **Scope:** HR stores only, tiered SKUs only (Gold/Silver/Bronze). Warehouse (jedinica=1) excluded.
- **Sales source:** `sales_detailed.csv`, filtered to `drzava=HR` and `tip_dok=RCM` (retail POS receipts).
- **Promo exclusion:** weeks where `erp_promo_calendar.csv` has `is_erp_promo=1` for the SKU are dropped from the avg/σ calc — promos inflate baseline demand and produce a too-loose "optimal".
- **Optimal stock per (SKU, store):** `safety_stock + cycle_stock` with **LT = 1 week**.
  - Safety stock = Z × σ × √LT, where σ is the std-dev of weekly non-promo sales, Z comes from the tier service level (Gold {service_gold:.0%}, Silver {service_silver:.0%}, Bronze {service_bronze:.0%}).
  - Cycle stock = LT × avg_weekly.
- **Overstock** = max(0, on_hand − optimal). SKU-store pairs with fewer than {MIN_WEEKS} weeks of non-promo sales are flagged "insufficient history" and excluded from overstock totals (their baseline is too unreliable).
- **€ valuation:** cost € uses `sku_costs.csv`, sell € uses `sku_prices.csv` (`avg_sell_price`). Missing prices → 0.
""")


def page_supply_settings():
    st.title("⚙️ Supply settings")

    p = st.session_state.get("sup_params", SUP_DEFAULTS.copy())

    st.subheader("Safety stock parameters")
    fa = st.slider("Forecast accuracy (FA)", 0.30, 0.95, p["fa"], 0.05,
                   help="Used as error proxy in the SS formula. "
                        "Measure per-tier via the Demand → Forecast accuracy page.")
    z = st.slider("Service level factor (Z)", 1.00, 2.50, p["z"], 0.05,
                  help="1.28 = 90% · 1.65 = 95% · 2.05 = 98% · 2.33 = 99%")
    tc = st.slider("Target coverage after reorder (weeks)", 4, 16,
                   int(p["target_coverage"]), 1)
    horizon = st.slider("Projection horizon (weeks)", 8, 26,
                        int(p["horizon"]), 1)

    st.session_state["sup_params"] = {
        "fa": fa, "z": z,
        "target_coverage": tc, "horizon": horizon,
    }

    st.subheader("Formula")
    st.latex(r"SS = Z \cdot \bar{D} \cdot (1 - FA) \cdot \sqrt{LT}")
    st.latex(r"ROP = \bar{D} \cdot LT + SS")
    st.latex(r"Q_{order} = \max\bigl(MOQ,\ T_{cov} \cdot \bar{D} - S_{on\_hand} - S_{incoming\_in\_LT}\bigr)")

    st.caption("Planned next: per-tier FA (Gold/Silver/Bronze), per-SKU FA "
               "override, before/after backtest of ROP crossings vs. actual stockouts.")


# ==================================================================
# COVERAGE WORKBOOK — refresh source sheets, keep POKRIVENOST formulas
# ==================================================================

COVERAGE_TEMPLATE = DATA_DIR / "coverage_template.xlsx"


def _refresh_coverage_workbook(out_path: Path) -> dict:
    """Load coverage_template.xlsx, refresh the 4 source sheets (Stock,
    Demand, Demand on-top, Incoming supply) with current CSVs / xlsx data,
    keep the POKRIVENOST sheet untouched (formulas + colour coding intact).
    Save to `out_path`. Returns a stats dict.
    """
    from openpyxl import load_workbook as _lw

    # Force fresh reads — clear the @st.cache_data layers that read stock,
    # store stock, incoming and forecast. Otherwise a 60s-stale cache from
    # a previous page render shadows what was just uploaded.
    try:
        sup_load_stock.clear()
        sup_load_stock_stores.clear()
        sup_load_incoming.clear()
        sup_load_forecast.clear()
        load_sales_data.clear()
    except Exception:
        pass

    if not COVERAGE_TEMPLATE.exists():
        return {"error": f"{COVERAGE_TEMPLATE.name} not found in data/. "
                          "Drop your Coverage_W*.xlsx as data/coverage_template.xlsx."}

    wb = _lw(COVERAGE_TEMPLATE)
    stats = {"stock": 0, "demand": 0, "ontop": 0, "incoming": 0,
             "demand_anchor": None, "incoming_anchor": None}

    cy, cw = get_current_cw()

    # ---- Detect ANCHOR weeks from template headers ----
    # POKRIVENOST formulas reference fixed source-sheet columns. We must
    # write data into the SAME column positions as the template expects.
    # Anchors: Demand col F holds the first forecast week ("Week NN" label
    # in row 3); Incoming supply col B holds week N (row 1 numeric label).
    demand_anchor = None
    if "Demand" in wb.sheetnames:
        v = wb["Demand"].cell(3, 6).value
        if isinstance(v, str) and v.lower().startswith("week"):
            try:
                demand_anchor = int(v.split()[-1])
            except ValueError:
                demand_anchor = None
    if demand_anchor is None:
        demand_anchor = cw  # safe fallback — current week
    stats["demand_anchor"] = demand_anchor

    incoming_anchor = None
    if "Incoming supply" in wb.sheetnames:
        v = wb["Incoming supply"].cell(1, 2).value
        if isinstance(v, (int, float)):
            incoming_anchor = int(v)
        elif isinstance(v, str):
            try:
                incoming_anchor = int(v)
            except ValueError:
                incoming_anchor = None
    if incoming_anchor is None:
        incoming_anchor = demand_anchor - 1
    stats["incoming_anchor"] = incoming_anchor

    # On-top sheet: align to demand_anchor (CW labels)
    ontop_anchor = demand_anchor

    # ---- Helper: clear data rows of a sheet, keep header rows ----
    def _clear_data(ws, header_rows: int):
        for r in range(header_rows + 1, ws.max_row + 1):
            for c in range(1, ws.max_column + 1):
                ws.cell(r, c).value = None

    # ---- 1. Stock sheet ----
    if "Stock" in wb.sheetnames:
        ws = wb["Stock"]
        # Read existing master columns from row 1 to learn the schema.
        # Polleo's stock sheet has SKU in col A and current on-hand in col 35.
        # WH ONLY — Coverage / POKRIVENOST is a warehouse-replenishment view.
        # Store stock is intentionally NOT folded in here; the Stock projection
        # page (full-company €) is where stores are added.
        stock_df = pd.read_csv(DATA_DIR / "stock.csv") if (DATA_DIR / "stock.csv").exists() else pd.DataFrame()
        stock_df.columns = [str(c).strip().lower() for c in stock_df.columns]
        if "sku" in stock_df.columns and "on_hand" in stock_df.columns:
            on_hand_map = dict(zip(stock_df["sku"].astype(str), stock_df["on_hand"]))
            # Master meta columns from sku_plan_list for context
            plan = pd.read_csv(DATA_DIR / "sku_plan_list.csv") if (DATA_DIR / "sku_plan_list.csv").exists() else pd.DataFrame()
            name_map = dict(zip(plan["sku"], plan.get("name", ""))) if "sku" in plan.columns else {}
            cat_map  = dict(zip(plan["sku"], plan.get("cat", ""))) if "sku" in plan.columns else {}
            ozn_map  = dict(zip(plan["sku"], plan.get("oznaka", ""))) if "sku" in plan.columns else {}

            _clear_data(ws, header_rows=1)
            r = 2
            for sku, on_hand in sorted(on_hand_map.items()):
                ws.cell(r, 1, sku)               # Šifra
                ws.cell(r, 2, cat_map.get(sku, ""))  # Kategorija
                ws.cell(r, 3, name_map.get(sku, ""))  # Item name
                ws.cell(r, 13, name_map.get(sku, ""))  # Naziv artikla (col 13)
                ws.cell(r, 35, float(on_hand))   # current week on-hand (col 35 = AI)
                r += 1
            stats["stock"] = r - 2

    # ---- 2. Demand sheet ----
    # Format: row 3 has headers SKU=col B, weeks start col F (Week 18 = col F).
    # Source: Polleo_Demand_Plan.xlsx Demand Output - Total (post-factor totals).
    plan_xlsx = DATA_DIR / "Polleo_Demand_Plan.xlsx"
    demand_per_sku_per_cw = {}   # {sku: {cw_int: units}}
    if plan_xlsx.exists():
        try:
            pwb = _lw(plan_xlsx, data_only=True)
            if "Demand Output - Total" in pwb.sheetnames:
                pws = pwb["Demand Output - Total"]
                cw_cols = {}
                for c in range(1, pws.max_column + 1):
                    v = pws.cell(4, c).value
                    if v and isinstance(v, str) and v.startswith("CW"):
                        try:
                            cw_cols[int(v[2:])] = c
                        except ValueError:
                            pass
                for r in range(5, pws.max_row + 1):
                    s = pws.cell(r, 1).value
                    if not s:
                        continue
                    per = {}
                    for cwn, col in cw_cols.items():
                        v = pws.cell(r, col).value
                        if isinstance(v, (int, float)):
                            per[cwn] = float(v)
                    if per:
                        demand_per_sku_per_cw[str(s)] = per
            pwb.close()
        except Exception:
            pass

    if "Demand" in wb.sheetnames and demand_per_sku_per_cw:
        ws = wb["Demand"]
        # Anchored at the template's existing Demand col F = "Week N".
        # Headers stay; only data rows get cleared and rewritten.
        first_week = demand_anchor
        n_weeks_cols = 17
        for j in range(n_weeks_cols):
            ws.cell(1, 6 + j, first_week + j)
            ws.cell(3, 6 + j, f"Week {first_week + j}")
        ws.cell(3, 2, "SKU")
        ws.cell(3, 3, "Artikl")
        ws.cell(3, 4, "Grupacija")
        ws.cell(3, 5, "OZNAKA")
        _clear_data(ws, header_rows=3)

        plan = pd.read_csv(DATA_DIR / "sku_plan_list.csv") if (DATA_DIR / "sku_plan_list.csv").exists() else pd.DataFrame()
        meta = {row["sku"]: row for _, row in plan.iterrows()} if len(plan) else {}
        r = 4
        for sku in sorted(demand_per_sku_per_cw.keys()):
            ws.cell(r, 2, sku)
            m = meta.get(sku, {})
            ws.cell(r, 3, m.get("name", "") if isinstance(m, dict) else m["name"] if "name" in m else "")
            ws.cell(r, 4, m.get("cat", "") if isinstance(m, dict) else m["cat"] if "cat" in m else "")
            ws.cell(r, 5, m.get("oznaka", "") if isinstance(m, dict) else m["oznaka"] if "oznaka" in m else "")
            for j in range(n_weeks_cols):
                wk = first_week + j
                v = demand_per_sku_per_cw[sku].get(wk, 0)
                ws.cell(r, 6 + j, float(v))
            r += 1
        stats["demand"] = r - 4

    # ---- 3. Demand on-top sheet ----
    ontop_per_sku_per_cw = {}
    for fname in ("vp_input.csv", "mp_input.csv"):
        p = DATA_DIR / fname
        if not p.exists():
            continue
        df = pd.read_csv(p)
        for _, row in df.iterrows():
            s = str(row["sku"])
            for c in df.columns:
                if isinstance(c, str) and c.startswith("CW"):
                    try:
                        cwn = int(c[2:])
                        v = float(row[c] or 0)
                    except (TypeError, ValueError):
                        continue
                    if v == 0:
                        continue
                    ontop_per_sku_per_cw.setdefault(s, {})
                    ontop_per_sku_per_cw[s][cwn] = ontop_per_sku_per_cw[s].get(cwn, 0) + v

    if "Demand on-top" in wb.sheetnames:
        ws = wb["Demand on-top"]
        first_week = ontop_anchor
        ws.cell(1, 1, "SKU")
        ws.cell(1, 2, "Artikl")
        ws.cell(1, 3, "Grupacija")
        ws.cell(1, 4, "OZNAKA")
        ws.cell(1, 5, "Avg RR")
        for j in range(17):
            ws.cell(1, 6 + j, f"CW{first_week + j}")
        _clear_data(ws, header_rows=1)

        # Avg RR — last 13 weeks total avg
        sc = load_sales_data()
        rr_map = {}
        if sc is not None and len(sc):
            yw = sc[["year", "week"]].drop_duplicates().sort_values(["year", "week"]).tail(13)
            keys = set(yw["year"].astype(int) * 100 + yw["week"].astype(int))
            yw_key = sc["year"].astype(int) * 100 + sc["week"].astype(int)
            sc13 = sc[yw_key.isin(keys)]
            agg = sc13.groupby("sku")["qty_total"].sum() / 13.0
            rr_map = agg.to_dict()

        plan = pd.read_csv(DATA_DIR / "sku_plan_list.csv") if (DATA_DIR / "sku_plan_list.csv").exists() else pd.DataFrame()
        meta = {row["sku"]: row for _, row in plan.iterrows()} if len(plan) else {}
        r = 2
        for sku in sorted(ontop_per_sku_per_cw.keys()):
            ws.cell(r, 1, sku)
            m = meta.get(sku, {})
            ws.cell(r, 2, m.get("name", "") if hasattr(m, "get") else "")
            ws.cell(r, 3, m.get("cat", "") if hasattr(m, "get") else "")
            ws.cell(r, 4, m.get("oznaka", "") if hasattr(m, "get") else "")
            ws.cell(r, 5, float(rr_map.get(sku, 0)))
            for j in range(17):
                wk = first_week + j
                v = ontop_per_sku_per_cw[sku].get(wk, 0)
                ws.cell(r, 6 + j, float(v))
            r += 1
        stats["ontop"] = r - 2

    # ---- 4. Incoming supply ----
    if "Incoming supply" in wb.sheetnames:
        ws = wb["Incoming supply"]
        # Incoming sheet anchored at col B = `incoming_anchor` (e.g. 17),
        # so col B+j = week (incoming_anchor + j).
        ws.cell(1, 1, "Row Labels")
        for j in range(17):
            ws.cell(1, 2 + j, incoming_anchor + j)
        _clear_data(ws, header_rows=1)

        inc_path = DATA_DIR / "incoming_supply.csv"
        if inc_path.exists():
            inc = pd.read_csv(inc_path)
            inc.columns = [str(c).strip().lower() for c in inc.columns]
            # Permissive: must have sku + week + qty. year is OPTIONAL —
            # if missing, assume current year (common in lightweight
            # ERP exports that only carry CW + SKU + qty).
            if {"sku", "week", "qty"} <= set(inc.columns):
                if "year" in inc.columns:
                    try:
                        inc = inc[inc["year"].astype(int) == cy]
                    except Exception:
                        pass
                # Coerce week to int and qty to float
                inc["week"] = pd.to_numeric(inc["week"], errors="coerce").astype("Int64")
                inc["qty"] = pd.to_numeric(inc["qty"], errors="coerce").fillna(0)
                inc = inc.dropna(subset=["week"])
                pivot = (inc.groupby(["sku", "week"], as_index=False)["qty"].sum()
                            .pivot(index="sku", columns="week", values="qty")
                            .fillna(0))
                r = 2
                for sku, row in pivot.iterrows():
                    ws.cell(r, 1, str(sku))
                    for j in range(17):
                        wk = incoming_anchor + j
                        if wk in row.index:
                            v = float(row[wk] or 0)
                            if v > 0:
                                ws.cell(r, 2 + j, v)
                    r += 1
                stats["incoming"] = r - 2
            else:
                stats["incoming_warning"] = (
                    f"incoming_supply.csv missing required columns. "
                    f"Got: {list(inc.columns)}. Need at least sku + week + qty."
                )

    # ---- 5a. POKRIVENOST alignment: delete inactive SKU blocks, append
    #          new GSB SKUs that are missing. Run BEFORE _patch_pokrivenost
    #          so the patch sees the final block list.
    if "POKRIVENOST" in wb.sheetnames:
        try:
            align_stats = _align_pokrivenost_skus(wb["POKRIVENOST"])
            stats["pokrivenost_aligned"] = align_stats
        except Exception as e:
            stats["pokrivenost_align_warning"] = f"alignment failed: {e}"

    # ---- 5b. POKRIVENOST surgery: hide past weeks, write current-week
    #          stock as a direct value, extend Order Status with
    #          "Pull in" / "Pull in + order more" cases.
    if "POKRIVENOST" in wb.sheetnames:
        try:
            _patch_pokrivenost(wb["POKRIVENOST"], cy, cw)
            stats["pokrivenost_patched"] = True
        except Exception as e:
            stats["pokrivenost_warning"] = f"POKRIVENOST patch failed: {e}"

    # Save
    wb.save(out_path)
    return stats


def _align_pokrivenost_skus(ws) -> dict:
    """Rewrite POKRIVENOST SKU blocks so they match the active GSB list
    (sku_plan_list.csv, oznaka ∈ {GOLD, SILVER, BRONZE}).

    - Removes blocks for SKUs no longer in the active list (de-listed).
    - De-duplicates repeated SKUs (keeps first occurrence).
    - Appends fresh blocks for active SKUs missing from the sheet.

    Each SKU is a 5-row block: Stock / Demand / Incoming supply / Closing
    stock / Coverage. Per-week formulas and styling are cloned from the
    first existing block and translated to the destination row, so
    intra-block references (Closing = Stock − Demand + Incoming, Stock
    walk-forward = previous Closing) stay correct in new blocks.

    Static cells (B–H, I) — supplier code, supplier name, lead time,
    grupacija, SKU, name, comment, row label — are overwritten with each
    block's own metadata. supply_master.csv supplies supplier info for new
    SKUs (blank if no entry).
    """
    import copy as _copy
    import re as _re
    from openpyxl.formula.translate import Translator
    from openpyxl.utils import get_column_letter
    from openpyxl.worksheet.formula import ArrayFormula

    HEADER_ROWS = 3
    BLOCK = 5
    LABELS = ["Stock", "Demand", "Incoming supply", "Closing stock", "Coverage"]

    # --- Active GSB SKUs from sku_plan_list.csv ---
    plan_path = DATA_DIR / "sku_plan_list.csv"
    if not plan_path.exists():
        return {"error": "sku_plan_list.csv not found — skipping alignment"}
    plan = pd.read_csv(plan_path)
    plan["oznaka_str"] = plan["oznaka"].astype(str)
    gsb = plan[plan["oznaka_str"].str.contains("GOLD|SILVER|BRONZE", na=False)]
    active_skus = {str(s).strip().upper() for s in gsb["sku"]}
    plan_meta = {
        str(r["sku"]).strip().upper(): {
            "sku_original": str(r["sku"]).strip(),
            "name": str(r.get("name", "") or ""),
            "cat": str(r.get("cat", "") or ""),
        }
        for _, r in gsb.iterrows()
    }

    # --- supply_master.csv for new SKUs (supplier / lead time) ---
    sm_path = DATA_DIR / "supply_master.csv"
    supply_master = {}
    if sm_path.exists():
        sm = pd.read_csv(sm_path)
        for _, r in sm.iterrows():
            supply_master[str(r["sku"]).strip().upper()] = {
                "supplier": r.get("supplier"),
                "lead_time_weeks": r.get("lead_time_weeks"),
            }

    # --- 1. Walk existing blocks; classify keep / drop / dedupe ---
    kept = []
    seen_upper = set()
    removed_skus = []
    deduped_skus = []
    for r in range(HEADER_ROWS + 1, ws.max_row + 1, BLOCK):
        sku = ws.cell(r, 6).value
        label = ws.cell(r, 9).value
        if label != "Stock" or not sku:
            continue
        sku_s = str(sku).strip()
        sku_u = sku_s.upper()
        if sku_u in seen_upper:
            deduped_skus.append(sku_s)
            continue
        seen_upper.add(sku_u)
        if sku_u not in active_skus:
            removed_skus.append(sku_s)
            continue
        kept.append({
            "sku": sku_s,
            "supplier_code": ws.cell(r, 2).value,
            "supplier_name": ws.cell(r, 3).value,
            "lead_time": ws.cell(r, 4).value,
            "grupacija": ws.cell(r, 5).value,
            "name": ws.cell(r, 7).value,
            "comment": ws.cell(r, 8).value,
        })

    # --- 2. Build new blocks for missing GSB SKUs ---
    kept_upper = {m["sku"].upper() for m in kept}
    missing_upper = sorted(active_skus - kept_upper)
    new_blocks = []
    for sku_u in missing_upper:
        pm = plan_meta.get(sku_u, {})
        smm = supply_master.get(sku_u, {})
        sup_str = str(smm.get("supplier", "") or "")
        sup_code, sup_name = "", ""
        if sup_str:
            parts = sup_str.split(" ", 1)
            sup_code = parts[0]
            sup_name = parts[1] if len(parts) > 1 else ""
        new_blocks.append({
            "sku": pm.get("sku_original", sku_u),
            "supplier_code": sup_code,
            "supplier_name": sup_name,
            "lead_time": smm.get("lead_time_weeks"),
            "grupacija": pm.get("cat", ""),
            "name": pm.get("name", ""),
            "comment": "",
        })

    all_blocks = kept + new_blocks
    if not all_blocks:
        return {"error": "no active SKUs to write — aborting alignment"}

    # --- 3. Capture template (rows 4..8) — formulas + styles ---
    template_r = HEADER_ROWS + 1
    max_col = ws.max_column
    template_cells = []
    for off in range(BLOCK):
        row_cells = []
        for col in range(1, max_col + 1):
            cell = ws.cell(template_r + off, col)
            v = cell.value
            row_cells.append({
                "value": v,
                "is_array_formula": isinstance(v, ArrayFormula),
                "number_format": cell.number_format,
                "fill": _copy.copy(cell.fill),
                "font": _copy.copy(cell.font),
                "alignment": _copy.copy(cell.alignment),
                "border": _copy.copy(cell.border),
            })
        template_cells.append(row_cells)

    # --- 4. Clear all SKU-block rows ---
    max_existing = ws.max_row
    for r in range(HEADER_ROWS + 1, max_existing + 1):
        for col in range(1, max_col + 1):
            ws.cell(r, col).value = None

    # --- 5. Write blocks from template (with row-ref translation) ---
    for idx, m in enumerate(all_blocks):
        block_row = HEADER_ROWS + 1 + idx * BLOCK
        for off in range(BLOCK):
            r = block_row + off
            for col in range(1, max_col + 1):
                tmpl = template_cells[off][col - 1]
                cell = ws.cell(r, col)
                cell.number_format = tmpl["number_format"]
                cell.fill = _copy.copy(tmpl["fill"])
                cell.font = _copy.copy(tmpl["font"])
                cell.alignment = _copy.copy(tmpl["alignment"])
                cell.border = _copy.copy(tmpl["border"])
                v = tmpl["value"]
                # Skip array formulas — _patch_pokrivenost rewrites the
                # Coverage formula to a simple per-cell version anyway.
                if tmpl["is_array_formula"]:
                    continue
                if isinstance(v, str) and v.startswith("="):
                    origin = f"{get_column_letter(col)}{template_r + off}"
                    dest = f"{get_column_letter(col)}{r}"
                    try:
                        cell.value = Translator(v, origin=origin).translate_formula(dest)
                    except Exception:
                        cell.value = v
                else:
                    cell.value = v
            # Overwrite static metadata for this block
            ws.cell(r, 2).value = m.get("supplier_code")
            ws.cell(r, 3).value = m.get("supplier_name")
            ws.cell(r, 4).value = m.get("lead_time")
            ws.cell(r, 5).value = m.get("grupacija")
            ws.cell(r, 6).value = m["sku"]
            ws.cell(r, 7).value = m.get("name")
            ws.cell(r, 8).value = m.get("comment")
            ws.cell(r, 9).value = LABELS[off]

    # --- 6. Trim trailing empty rows ---
    new_max_row = HEADER_ROWS + len(all_blocks) * BLOCK
    if max_existing > new_max_row:
        ws.delete_rows(new_max_row + 1, max_existing - new_max_row)

    # --- 7. Update sheet-level ranges (AutoFilter, row-1 SUBTOTALs) ---
    if ws.auto_filter.ref and ":" in ws.auto_filter.ref:
        s, e = ws.auto_filter.ref.split(":")
        m_end = _re.match(r"([A-Z]+)", e)
        if m_end:
            ws.auto_filter.ref = f"{s}:{m_end.group(1)}{new_max_row}"
    for col in range(1, max_col + 1):
        v = ws.cell(1, col).value
        if isinstance(v, str) and v.startswith("=SUBTOTAL"):
            ws.cell(1, col).value = _re.sub(
                r"([A-Z]+)\d+:([A-Z]+)\d+",
                lambda mm: f"{mm.group(1)}{HEADER_ROWS + 1}:{mm.group(2)}{new_max_row}",
                v,
            )

    # --- 8. Rewrite Incoming-supply + Demand formulas with the correct
    #         source-col mapping per week. The template had broken column
    #         refs in the Incoming row (skipped cols H/J/L around Weeks
    #         23-25, then stayed off-by-3 to the end) — Translator only
    #         shifts row numbers, not cross-sheet column letters, so the
    #         skip survived a verbatim copy. Compute src col from the
    #         anchor weeks of the source sheets and write fresh formulas.
    wb_parent = ws.parent
    incoming_anchor = None
    if "Incoming supply" in wb_parent.sheetnames:
        v = wb_parent["Incoming supply"].cell(1, 2).value
        if isinstance(v, (int, float)):
            incoming_anchor = int(v)
        elif isinstance(v, str):
            try:
                incoming_anchor = int(v)
            except ValueError:
                pass
    demand_anchor = None
    if "Demand" in wb_parent.sheetnames:
        v = wb_parent["Demand"].cell(3, 6).value
        if isinstance(v, str) and v.lower().startswith("week"):
            try:
                demand_anchor = int(v.split()[-1])
            except ValueError:
                pass

    pok_week_col = {}
    for c in range(1, max_col + 1):
        v = ws.cell(3, c).value
        if isinstance(v, str) and v.lower().startswith("week"):
            try:
                pok_week_col[c] = int(v.split()[-1])
            except ValueError:
                pass

    n_inc = 0
    n_dem = 0
    for idx in range(len(all_blocks)):
        block_row = HEADER_ROWS + 1 + idx * BLOCK
        dem_r = block_row + 1
        inc_r = block_row + 2
        for c, wk in pok_week_col.items():
            if incoming_anchor is not None:
                inc_src_idx = wk - incoming_anchor + 2   # col B = anchor
                if inc_src_idx >= 2:
                    letter = get_column_letter(inc_src_idx)
                    ws.cell(inc_r, c).value = (
                        f"=IFERROR(_xlfn.XLOOKUP($F{inc_r},'Incoming supply'!$A:$A,"
                        f"'Incoming supply'!{letter}:{letter}),0)"
                    )
                    n_inc += 1
            if demand_anchor is not None:
                dem_src_idx = wk - demand_anchor + 6     # col F = anchor
                if dem_src_idx >= 2:
                    letter = get_column_letter(dem_src_idx)
                    ws.cell(dem_r, c).value = (
                        f"=IFERROR(ROUND(_xlfn.XLOOKUP($F{dem_r},Demand!$B:$B,"
                        f"Demand!{letter}:{letter}),0),0)"
                    )
                    n_dem += 1

    return {
        "kept": len(kept),
        "removed": len(removed_skus),
        "deduped": len(deduped_skus),
        "added": len(new_blocks),
        "removed_skus": removed_skus[:20],
        "added_skus": [b["sku"] for b in new_blocks][:20],
        "incoming_anchor": incoming_anchor,
        "demand_anchor": demand_anchor,
        "incoming_formulas_rewritten": n_inc,
        "demand_formulas_rewritten": n_dem,
    }


def _patch_pokrivenost(ws, cy: int, cw: int):
    """Apply 5 changes to the POKRIVENOST sheet in-place:

    (1) Replace per-week ArrayFormula Coverage with simple formula:
           Coverage = Stock / AVERAGE(Demand next 8 weeks)
        — intuitive: stock drops → cover drops. The old LET/SCAN/XMATCH
        forward-walk produced rising cover when forecast demand dropped
        to zero past horizon, confusing the planner.
    (2) Replace ALL past-week (Week 14 … Week cw-1) formula cells with
        HARD VALUES — Demand from sales_clean actuals, Stock/Closing/
        Coverage = 0 (no historical stock snapshots available).
    (3) Hide past-week columns (set outline + hidden).
    (4) Current-week Stock = direct value from data/stock.csv.
    (5) Order Status formula extended with 🔵 Pull in / Pull in + order more.
    """
    from openpyxl.utils import get_column_letter as _CL

    # --- Map Week N label → column index ---
    week_col = {}
    for c in range(1, ws.max_column + 1):
        v = ws.cell(3, c).value
        if isinstance(v, str) and v.lower().startswith("week"):
            try:
                wk = int(v.split()[-1])
                week_col[wk] = c
            except ValueError:
                pass

    # --- Hide past weeks AND weeks beyond the 13-week horizon ---
    # Visible window: current week .. current week + 13 (e.g. W20..W33).
    # Order Status / SS Weeks / Reorder / Eff. Coverage columns stay visible.
    horizon_end = cw + 13
    past_cols = []
    past_letters = []
    future_cols = []
    future_letters = []
    for wk, col in sorted(week_col.items()):
        letter = _CL(col)
        if wk < cw:
            past_letters.append(letter)
            past_cols.append((wk, col))
        elif wk > horizon_end:
            future_letters.append(letter)
            future_cols.append((wk, col))

    def _hide_range(letters, cols):
        if not letters:
            return
        try:
            ws.column_dimensions.group(
                letters[0], letters[-1],
                hidden=True, outline_level=1,
            )
        except Exception:
            for letter, col in zip(letters, cols):
                cd = ws.column_dimensions[letter]
                cd.min = col
                cd.max = col
                cd.hidden = True
                cd.outline_level = 1

    _hide_range(past_letters, [c for _, c in past_cols])
    _hide_range(future_letters, [c for _, c in future_cols])

    # --- Load past Demand actuals from sales_clean.csv ---
    past_demand_map = {}   # {(sku, week): actual_qty}
    sales_path = DATA_DIR / "sales_clean.csv"
    if sales_path.exists() and past_cols:
        try:
            sdf = pd.read_csv(sales_path)
            sdf.columns = [str(c).strip().lower() for c in sdf.columns]
            past_weeks_set = {w for w, _ in past_cols}
            if {"sku", "year", "week"} <= set(sdf.columns):
                sub = sdf[(sdf["year"].astype(int) == cy)
                            & (sdf["week"].astype(int).isin(past_weeks_set))]
                for _, r in sub.iterrows():
                    s = str(r["sku"])
                    wk = int(r["week"])
                    qty = float(r.get("qty_retail", 0) or 0) + float(r.get("qty_webshop", 0) or 0)
                    past_demand_map[(s, wk)] = qty
        except Exception:
            pass

    # --- Load past Incoming actuals ---
    past_incoming_map = {}
    inc_path = DATA_DIR / "incoming_supply.csv"
    if inc_path.exists() and past_cols:
        try:
            idf = pd.read_csv(inc_path)
            idf.columns = [str(c).strip().lower() for c in idf.columns]
            past_weeks_set = {w for w, _ in past_cols}
            if {"sku", "week", "qty"} <= set(idf.columns):
                sub = idf[idf["week"].astype(int).isin(past_weeks_set)]
                if "year" in sub.columns:
                    try:
                        sub = sub[sub["year"].astype(int) == cy]
                    except Exception:
                        pass
                grouped = sub.groupby(["sku", "week"])["qty"].sum().to_dict()
                for (s, wk), q in grouped.items():
                    past_incoming_map[(str(s), int(wk))] = float(q or 0)
        except Exception:
            pass

    cur_col = week_col.get(cw)
    if cur_col is None:
        # Current week outside template range — skip stock + flag patches
        return
    cur_letter = _CL(cur_col)

    # --- (2) Direct stock value for current week ---
    # WH ONLY — POKRIVENOST tracks warehouse replenishment. Store stock is
    # intentionally NOT folded in (the Stock projection page handles that
    # for the full-company €/units view).
    stock_map = {}
    stock_path = DATA_DIR / "stock.csv"
    if stock_path.exists():
        sdf = pd.read_csv(stock_path)
        sdf.columns = [str(c).strip().lower() for c in sdf.columns]
        if "sku" in sdf.columns and "on_hand" in sdf.columns:
            stock_map = dict(zip(sdf["sku"].astype(str),
                                    pd.to_numeric(sdf["on_hand"], errors="coerce").fillna(0)))

    # Future-week columns sorted (used to build per-cell Coverage formula
    # that references the next 8 weeks of demand).
    sorted_weeks = sorted(week_col.items())
    week_to_col_sorted = {w: c for w, c in sorted_weeks}

    # --- Shadow demand: per-SKU avg weekly base demand (no on-top) ---
    # Trailing 26 weeks of qty_retail + qty_webshop from sales_clean.
    # Written into every future-week cell of the Demand row so the
    # Coverage formula always has a meaningful denominator and never
    # returns 99 (no-data) for SKUs lacking forward forecast.
    sku_avg_demand = {}
    if sales_path.exists():
        try:
            sdf2 = pd.read_csv(sales_path)
            sdf2.columns = [str(c).strip().lower() for c in sdf2.columns]
            if {"sku", "year", "week"} <= set(sdf2.columns):
                sdf2["yw"] = sdf2["year"].astype(int) * 100 + sdf2["week"].astype(int)
                cyw = cy * 100 + cw
                sdf2 = sdf2[sdf2["yw"] < cyw].copy()
                sdf2["qty"] = (
                    pd.to_numeric(sdf2.get("qty_retail", 0), errors="coerce").fillna(0)
                    + pd.to_numeric(sdf2.get("qty_webshop", 0), errors="coerce").fillna(0)
                )
                recent_yws = sorted(sdf2["yw"].unique())[-26:]
                if recent_yws:
                    sdf2 = sdf2[sdf2["yw"].isin(recent_yws)]
                    grp = sdf2.groupby("sku")["qty"].mean()
                    sku_avg_demand = {str(k): float(v) for k, v in grp.items()}
        except Exception:
            pass

    # Walk SKU blocks — every 5 rows starting at row 4: Stock, Demand,
    # Incoming, Closing, Coverage.
    BLOCK = 5
    for stock_row in range(4, ws.max_row + 1, BLOCK):
        sku = ws.cell(stock_row, 6).value   # col F = Šifra artikla
        if not sku:
            continue
        label = ws.cell(stock_row, 9).value
        if label != "Stock":
            continue
        sku_str = str(sku)
        dem_row = stock_row + 1
        inc_row = stock_row + 2
        cs_row  = stock_row + 3
        cov_row = stock_row + 4

        # ---- (2) Past-week cells → HARD VALUES ----
        for wk, col in past_cols:
            # Stock: 0 (no historical stock snapshot)
            ws.cell(stock_row, col).value = 0
            # Demand: actual sales from sales_clean
            ws.cell(dem_row, col).value = round(past_demand_map.get((sku_str, wk), 0))
            # Incoming: actual from incoming_supply
            ws.cell(inc_row, col).value = round(past_incoming_map.get((sku_str, wk), 0))
            # Closing stock: 0 (no historical chain)
            ws.cell(cs_row, col).value = 0
            # Coverage: 0
            ws.cell(cov_row, col).value = 0

        # ---- (4) Current-week Stock = direct value ----
        stock_val = float(stock_map.get(sku_str, 0))
        ws.cell(stock_row, cur_col).value = stock_val

        # ---- Shadow demand for weeks BEYOND the 13-week horizon ----
        # W cw..cw+13 keep their existing forecast+on-top values/formulas.
        # W cw+14..end get the SKU's avg weekly base demand as fallback so
        # Coverage is defined across the full year.
        avg_d = round(sku_avg_demand.get(sku_str, 0))
        for wk, col in sorted_weeks:
            if wk <= horizon_end:
                continue
            ws.cell(dem_row, col).value = avg_d

        # ---- (1) Replace Coverage formula — FIXED denominator ----
        # Coverage = stock_this_week / avg(non-zero demand across full year).
        # Demand row is fully populated: W cw..cw+13 = real forecast + on-top,
        # W cw+14..end = shadow avg base demand. The denominator is the SAME
        # for every coverage cell of this SKU (absolute reference). Coverage
        # drops monotonically as stock drops.
        last_letter = _CL(max(week_col.values()))
        for wk, col in sorted_weeks:
            if wk < cw:
                continue
            letter = _CL(col)
            new_cov = (
                f'=IF({letter}{stock_row}<=0,0,'
                f'IF(COUNTIF(${cur_letter}${dem_row}:${last_letter}${dem_row},">0")=0,99,'
                f'{letter}{stock_row}/AVERAGEIF(${cur_letter}${dem_row}:${last_letter}${dem_row},">0")))'
            )
            ws.cell(cov_row, col).value = new_cov

        # ---- (5) Order Status with Pull in cases ----
        # 13-week incoming horizon: current week + 12 ahead (or end of sheet)
        inc_end_col = min(cur_col + 12, max(week_col.values()))
        inc_end_letter = _CL(inc_end_col)
        dem_end_col = min(cur_col + 7, max(week_col.values()))
        dem_end_letter = _CL(dem_end_col)

        new_status = (
            f'=IF(D{cov_row}="","",'
            f'IF(BJ{cov_row}="","No Data",'
            f'IF(BJ{cov_row}<BI{cov_row},'
                f'IF(AND({cur_letter}{cs_row}<=0,SUM({cur_letter}{inc_row}:{inc_end_letter}{inc_row})>0),'
                    f'IF(({cur_letter}{cs_row}+SUM({cur_letter}{inc_row}:{inc_end_letter}{inc_row}))/'
                    f'MAX(AVERAGE({cur_letter}{dem_row}:{dem_end_letter}{dem_row}),1)'
                    f'>=BI{cov_row}*1.5,"🔵 Pull in","🔵 Pull in + order more"),'
                    f'"🔴 Order Now"),'
            f'IF(BJ{cov_row}<BI{cov_row}*1.5,"🟡 Order Soon","✅ OK"))))'
        )
        ws.cell(cov_row, 59).value = new_status


def page_supply_coverage_workbook():
    st.title("📊 Coverage workbook")
    st.caption(
        "One-click refresh of the Coverage workbook (POKRIVENOST). The "
        "**source sheets** (Stock, Demand, Demand on-top, Incoming supply) "
        "are rewritten with current data; the POKRIVENOST sheet — including "
        "all formulas, colour coding, supplier / LT / tier columns, SKU and "
        "name — stays untouched."
    )

    if not COVERAGE_TEMPLATE.exists():
        st.warning(
            "No coverage template found. Drop your existing "
            "`Coverage_W*.xlsx` into `data/` and rename it to "
            "`coverage_template.xlsx`. The workbook keeps its layout, "
            "formulas and colours forever — we only refresh the data sheets."
        )
        return

    # List previous outputs
    cy, cw = get_current_cw()
    out_files = sorted(DATA_DIR.glob("Coverage_W*.xlsx"), reverse=True)
    if out_files:
        with st.expander("Previously generated coverage files"):
            for f in out_files[:10]:
                ts = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
                st.caption(f"• {f.name}  ({ts})")

    # Show source-file mtimes so DP can confirm uploads landed before clicking Refresh
    st.markdown("**Source files used by Refresh:**")
    src_rows = []
    for label, fname in [
        ("WH stock", "stock.csv"),
        ("Stores HR", "stock_stores.csv"),
        ("Stores AT", "stock_stores_at.csv"),
        ("Stores SLO", "stock_stores_slo.csv"),
        ("Incoming POs", "incoming_supply.csv"),
        ("VP on-top", "vp_input.csv"),
        ("MP on-top", "mp_input.csv"),
        ("Demand Plan xlsx", "Polleo_Demand_Plan.xlsx"),
    ]:
        p = DATA_DIR / fname
        if p.exists():
            ts = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
            src_rows.append({"File": fname, "Status": "✅", "Last modified": ts,
                              "Description": label})
        else:
            src_rows.append({"File": fname, "Status": "⚪ missing", "Last modified": "—",
                              "Description": label})
    st.dataframe(pd.DataFrame(src_rows), use_container_width=True, hide_index=True)
    st.caption(
        "If a file you just uploaded shows old `Last modified` time, the upload "
        "didn't land — go back to **Supply → Upload data** and re-upload. "
        "Refresh always reads disk fresh, no caches involved."
    )

    if st.button("🔄 Refresh coverage workbook now", type="primary",
                  key="cov_refresh"):
        out_path = DATA_DIR / f"Coverage_W{cw}.xlsx"
        with st.spinner("Refreshing source sheets…"):
            try:
                stats = _refresh_coverage_workbook(out_path)
            except Exception as e:
                st.error(f"Refresh failed: {e}")
                stats = None
        if stats:
            if stats.get("error"):
                st.error(stats["error"])
            else:
                st.success(
                    f"Saved {out_path.name}. "
                    f"Stock: {stats['stock']} SKUs · "
                    f"Demand: {stats['demand']} SKUs · "
                    f"On-top: {stats['ontop']} SKUs · "
                    f"Incoming: {stats['incoming']} POs."
                )
                if stats.get("incoming_warning"):
                    st.warning(f"⚠️ Incoming: {stats['incoming_warning']}")
                if stats.get("pokrivenost_aligned"):
                    a = stats["pokrivenost_aligned"]
                    if a.get("error"):
                        st.warning(f"⚠️ POKRIVENOST alignment: {a['error']}")
                    else:
                        st.caption(
                            f"🧹 POKRIVENOST aligned to active GOLD/SILVER/BRONZE list: "
                            f"**{a['kept']}** kept · **{a['removed']}** removed · "
                            f"**{a['deduped']}** de-duped · **{a['added']}** added."
                        )
                        if a.get("removed_skus"):
                            with st.expander(f"Removed SKUs ({a['removed']})"):
                                st.write(a["removed_skus"])
                        if a.get("added_skus"):
                            with st.expander(f"Added SKUs ({a['added']})"):
                                st.write(a["added_skus"])
                if stats.get("pokrivenost_align_warning"):
                    st.warning(f"⚠️ POKRIVENOST alignment: {stats['pokrivenost_align_warning']}")
                if stats.get("pokrivenost_patched"):
                    st.caption(
                        f"📌 POKRIVENOST patched: past weeks (< CW{cw}) hidden, "
                        f"current-week stock written as hard value, "
                        "Order Status extended with 🔵 Pull in / 🔵 Pull in + order more."
                    )
                if stats.get("pokrivenost_warning"):
                    st.warning(f"⚠️ POKRIVENOST: {stats['pokrivenost_warning']}")
                if stats["stock"] == 0:
                    st.warning(
                        "⚠️ Stock count is 0 — `stock.csv` was not read. "
                        "Re-upload via Supply → Upload data → WH stock."
                    )
                if stats["incoming"] == 0:
                    st.info(
                        "ℹ️ Incoming count is 0 — either no PO-ovi za horizon "
                        "ili `incoming_supply.csv` ima različit format. "
                        "Provjeri 'Source files' tablicu iznad."
                    )
                st.caption(
                    f"Week alignment (read from template) — "
                    f"Demand col F = Week {stats['demand_anchor']} · "
                    f"Incoming col B = Week {stats['incoming_anchor']} · "
                    f"On-top col F = CW{stats['demand_anchor']}. "
                    "POKRIVENOST formulas pull from these exact column "
                    "positions, so weeks match end-to-end."
                )
                with open(out_path, "rb") as f:
                    st.download_button(
                        "⬇️ Download Coverage workbook",
                        f.read(),
                        file_name=out_path.name,
                        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                        type="primary",
                    )
                st.caption(
                    "Open the workbook in Excel — formulas in POKRIVENOST will "
                    "auto-recalculate when the file opens. Colour coding and "
                    "supplier / LT / tier columns are preserved."
                )


# ==================================================================
# NPD MODULE — New Product Development tracking
# ==================================================================

NPD_FILE = DATA_DIR / "npd_skus.csv"
NPD_COLS = ["sku", "name", "category", "launch_year", "launch_week",
            "proj_retail_weekly", "proj_wholesale_weekly",
            "proj_horizon_weeks", "added_date", "notes"]


def _load_npd():
    if not NPD_FILE.exists():
        return pd.DataFrame(columns=NPD_COLS)
    df = pd.read_csv(NPD_FILE)
    for c in NPD_COLS:
        if c not in df.columns:
            df[c] = "" if c in ("name", "category", "notes", "added_date") else 0
    return df[NPD_COLS]


def _save_npd(df: pd.DataFrame):
    df = df.copy()
    for c in NPD_COLS:
        if c not in df.columns:
            df[c] = "" if c in ("name", "category", "notes", "added_date") else 0
    df = df[NPD_COLS]
    df.to_csv(NPD_FILE, index=False)


def page_npd_upload():
    st.title("🆕 NPD — Upload / Add new SKUs")
    st.caption("Drop an Excel/CSV from the NPD department, or add SKUs manually. "
               "Existing entries with the same SKU are overwritten.")

    df = _load_npd()
    st.write(f"Current NPD list: **{len(df)} SKUs**")

    # ---- Excel / CSV upload ----
    st.subheader("📤 Excel / CSV upload")
    st.caption("Required columns: **sku, name**. Optional: category, launch_year, "
               "launch_week, proj_retail_weekly, proj_wholesale_weekly, "
               "proj_horizon_weeks, notes. Header row is auto-lowercased.")

    up = st.file_uploader("Upload .xlsx or .csv", type=["xlsx", "csv"], key="npd_up")
    if up is not None:
        try:
            if up.name.lower().endswith(".csv"):
                up_df = pd.read_csv(up)
            else:
                up_df = pd.read_excel(up)
            up_df.columns = [str(c).strip().lower() for c in up_df.columns]

            if "sku" not in up_df.columns or "name" not in up_df.columns:
                st.error("File must include at least `sku` and `name` columns.")
            else:
                # Fill missing optional columns with sensible defaults.
                today_str = datetime.now().strftime("%Y-%m-%d")
                for col, default in [
                    ("category", ""), ("launch_year", 0), ("launch_week", 0),
                    ("proj_retail_weekly", 0), ("proj_wholesale_weekly", 0),
                    ("proj_horizon_weeks", 13),
                    ("added_date", today_str), ("notes", ""),
                ]:
                    if col not in up_df.columns:
                        up_df[col] = default
                up_df = up_df[NPD_COLS]
                st.dataframe(up_df.head(20), use_container_width=True, hide_index=True)
                st.caption(f"Preview: {len(up_df)} rows. Saving overwrites existing "
                           "rows by SKU and appends new ones.")
                if st.button("💾 Save uploaded SKUs", type="primary", key="npd_save_up"):
                    df_old = _load_npd()
                    incoming_skus = set(up_df["sku"].astype(str))
                    df_kept = df_old[~df_old["sku"].astype(str).isin(incoming_skus)]
                    merged = pd.concat([df_kept, up_df], ignore_index=True)
                    _save_npd(merged)
                    st.success(f"Saved. List now has {len(merged)} SKUs.")
                    st.rerun()
        except Exception as e:
            st.error(f"Could not read file: {e}")

    st.divider()

    # ---- Manual add ----
    st.subheader("✏️ Add a single SKU manually")
    cy, cw = get_current_cw()
    with st.form("npd_manual", clear_on_submit=True):
        c1, c2 = st.columns([1, 2])
        sku = c1.text_input("SKU code *", key="npd_man_sku")
        name = c2.text_input("Product name *", key="npd_man_name")
        c3, c4 = st.columns(2)
        category = c3.text_input("Category", key="npd_man_cat")
        notes = c4.text_input("Notes", key="npd_man_notes")
        c5, c6, c7 = st.columns(3)
        launch_year = c5.number_input("Launch year", min_value=2024, max_value=2030,
                                       value=cy, step=1, key="npd_man_ly")
        launch_week = c6.number_input("Launch ISO week", min_value=1, max_value=53,
                                       value=cw, step=1, key="npd_man_lw")
        horizon = c7.number_input("Projection horizon (weeks)",
                                    min_value=1, max_value=52, value=13, step=1,
                                    key="npd_man_h")
        c8, c9 = st.columns(2)
        proj_retail = c8.number_input("Projected retail (units / week)",
                                        min_value=0, value=0, step=10,
                                        key="npd_man_pr")
        proj_ws = c9.number_input("Projected wholesale (units / week)",
                                    min_value=0, value=0, step=10,
                                    key="npd_man_pw")
        if st.form_submit_button("➕ Add SKU", type="primary"):
            if not sku.strip() or not name.strip():
                st.error("SKU and Name are required.")
            else:
                df_cur = _load_npd()
                df_cur = df_cur[df_cur["sku"].astype(str) != str(sku).strip()]
                new_row = {
                    "sku": sku.strip(), "name": name.strip(),
                    "category": category.strip(),
                    "launch_year": int(launch_year), "launch_week": int(launch_week),
                    "proj_retail_weekly": int(proj_retail),
                    "proj_wholesale_weekly": int(proj_ws),
                    "proj_horizon_weeks": int(horizon),
                    "added_date": datetime.now().strftime("%Y-%m-%d"),
                    "notes": notes.strip(),
                }
                df_new = pd.concat([df_cur, pd.DataFrame([new_row])], ignore_index=True)
                _save_npd(df_new)
                st.success(f"Added {sku.strip()}. List now has {len(df_new)} SKUs.")
                st.rerun()

    st.divider()

    # ---- Current list with delete option ----
    st.subheader(f"Current NPD list ({len(df)})")
    if len(df) == 0:
        st.info("Empty — add your first NPD SKU above.")
    else:
        st.dataframe(df, use_container_width=True, hide_index=True)
        with st.expander("🗑️ Delete an entry"):
            del_sku = st.selectbox("Pick SKU to delete",
                                    [""] + df["sku"].astype(str).tolist(),
                                    key="npd_del")
            if del_sku and st.button("Confirm delete", key="npd_del_btn"):
                df_kept = df[df["sku"].astype(str) != del_sku]
                _save_npd(df_kept)
                st.success(f"Deleted {del_sku}.")
                st.rerun()

    # Template download
    st.divider()
    st.caption("📋 Need a starter template?")
    import io as _io
    from openpyxl import Workbook as _WB
    from openpyxl.styles import Font as _F, PatternFill as _PF, Alignment as _A
    from openpyxl.utils import get_column_letter as _CL

    _buf = _io.BytesIO()
    _wb = _WB()
    _ws = _wb.active
    _ws.title = "NPD template"
    _ws.cell(1, 1, "NPD upload template — fill rows from row 4 down. Required: sku, name. Other columns optional.").font = _F(italic=True, size=10, color="555555")

    cols_t = ["sku", "name", "category", "launch_year", "launch_week",
              "proj_retail_weekly", "proj_wholesale_weekly",
              "proj_horizon_weeks", "added_date", "notes"]
    for ci, name in enumerate(cols_t, 1):
        c = _ws.cell(3, ci, name)
        c.font = _F(bold=True, color="FFFFFF", size=10)
        c.fill = _PF("solid", fgColor="2F5496")
        c.alignment = _A(horizontal="center")

    example = ["DEMO_NEW_001", "New product example", "PROTEINI",
               cy, cw + 4, 50, 200, 13,
               datetime.now().strftime("%Y-%m-%d"), "Optional notes"]
    for ci, val in enumerate(example, 1):
        _ws.cell(4, ci, val)

    widths = {"sku": 14, "name": 38, "category": 22, "launch_year": 12,
              "launch_week": 12, "proj_retail_weekly": 18,
              "proj_wholesale_weekly": 20, "proj_horizon_weeks": 18,
              "added_date": 14, "notes": 30}
    for ci, name in enumerate(cols_t, 1):
        _ws.column_dimensions[_CL(ci)].width = widths.get(name, 14)
    _ws.freeze_panes = "A4"
    _wb.save(_buf)

    st.download_button(
        "⬇️ Download blank template (Excel)",
        _buf.getvalue(),
        file_name="npd_template.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )


def page_npd_list():
    st.title("🆕 NPD — list & sales tracking")
    st.caption("Projected vs. actual sell-through for every NPD SKU. "
               "Actuals are pulled from `sales_clean.csv` after each weekly "
               "Update Sales — no separate upload needed.")

    df = _load_npd()
    if len(df) == 0:
        st.info("No NPD SKUs yet. Go to **Upload / Add SKUs** to add some.")
        return

    sc = load_sales_data()
    iso = datetime.now().isocalendar()
    cur_y, cur_w = int(iso[0]), int(iso[1])

    # Aggregate actuals per NPD SKU since launch (or last 13w if no launch date).
    rows_out = []
    for _, r in df.iterrows():
        sku = str(r["sku"])
        try:
            ly = int(r.get("launch_year") or 0)
            lw = int(r.get("launch_week") or 0)
        except Exception:
            ly, lw = 0, 0
        horizon = max(1, int(r.get("proj_horizon_weeks") or 13))
        proj_r = int(r.get("proj_retail_weekly") or 0)
        proj_w = int(r.get("proj_wholesale_weekly") or 0)

        act_r = act_w = act_total = 0
        weeks_with_data = 0
        if sc is not None and sku in set(sc["sku"].astype(str)):
            sub = sc[sc["sku"].astype(str) == sku]
            if ly > 0 and lw > 0:
                key = sub["year"].astype(int) * 100 + sub["week"].astype(int)
                sub = sub[key >= (ly * 100 + lw)]
            if len(sub) > 0:
                act_r = int(sub["qty_retail"].sum() + sub.get("qty_webshop", 0).sum())
                act_w = int(sub["qty_wholesale"].sum())
                act_total = int(sub["qty_total"].sum())
                weeks_with_data = int(sub[["year", "week"]].drop_duplicates().shape[0])

        proj_total_period = (proj_r + proj_w) * horizon
        # Target for the realised window (proportional to weeks already lived)
        if weeks_with_data > 0:
            target_to_date = (proj_r + proj_w) * weeks_with_data
            attainment = (act_total / target_to_date * 100) if target_to_date > 0 else 0
        else:
            attainment = 0

        launch_label = f"CW{lw}/{ly}" if ly and lw else "—"
        rows_out.append({
            "SKU": sku,
            "Name": r.get("name", ""),
            "Category": r.get("category", ""),
            "Launch": launch_label,
            "Proj retail/wk": proj_r,
            "Proj WS/wk": proj_w,
            "Proj total ({}w)".format(horizon): proj_total_period,
            "Weeks live": weeks_with_data,
            "Actual retail": act_r,
            "Actual WS": act_w,
            "Actual total": act_total,
            "Attainment %": round(attainment, 1),
        })

    out_df = pd.DataFrame(rows_out)

    # Headline KPIs
    n_skus = len(out_df)
    n_live = int((out_df["Weeks live"] > 0).sum())
    total_actual = int(out_df["Actual total"].sum())
    avg_attainment = float(out_df.loc[out_df["Weeks live"] > 0, "Attainment %"].mean()
                             if n_live else 0)
    m1, m2, m3, m4 = st.columns(4)
    m1.metric("NPD SKUs", n_skus)
    m2.metric("Already live", n_live)
    m3.metric("Total actual units", f"{total_actual:,}")
    m4.metric("Avg attainment %", f"{avg_attainment:.1f}%" if n_live else "—",
               help="Average of per-SKU (actual / projected for weeks lived) "
                    "across SKUs that have at least 1 week of data.")

    # Filters
    cats = sorted([c for c in out_df["Category"].unique() if c])
    f1, f2 = st.columns([1, 1])
    cat_pick = f1.selectbox("Category", ["All"] + cats, key="npd_list_cat")
    show_only_live = f2.checkbox("Only SKUs with sales", value=False, key="npd_list_live")

    view = out_df.copy()
    if cat_pick != "All":
        view = view[view["Category"] == cat_pick]
    if show_only_live:
        view = view[view["Weeks live"] > 0]

    # Color attainment column
    def _color_att(v):
        try:
            v = float(v)
        except Exception:
            return ""
        if v >= 90: return "background-color: #C6EFCE; color: #006100"
        if v >= 70: return "background-color: #FFEB9C; color: #9C6500"
        if v > 0:   return "background-color: #FFC7CE; color: #9C0006"
        return ""

    styled = view.style.format({
        "Proj retail/wk": "{:,.0f}",
        "Proj WS/wk": "{:,.0f}",
        out_df.columns[6]: "{:,.0f}",  # Proj total
        "Actual retail": "{:,.0f}",
        "Actual WS": "{:,.0f}",
        "Actual total": "{:,.0f}",
        "Attainment %": "{:.1f}%",
    }).applymap(_color_att, subset=["Attainment %"])

    st.dataframe(styled, use_container_width=True, hide_index=True)

    # Download Excel
    import io
    from openpyxl import Workbook
    from openpyxl.styles import Font, PatternFill, Alignment

    buf = io.BytesIO()
    wb = Workbook()
    ws = wb.active
    ws.title = "NPD tracker"
    ws.cell(1, 1, f"NPD tracker — CW{cur_w}/{cur_y}").font = Font(bold=True, size=14, color="1F3A5F")
    hdr_fill = PatternFill("solid", fgColor="2F5496")
    hdr_font = Font(bold=True, color="FFFFFF", size=10)
    cols_list = list(view.columns)
    for ci, name in enumerate(cols_list, 1):
        c = ws.cell(3, ci, str(name))
        c.font = hdr_font
        c.fill = hdr_fill
        c.alignment = Alignment(horizontal="center")
    for ri, (_, row) in enumerate(view.iterrows(), 4):
        for ci, name in enumerate(cols_list, 1):
            v = row[name]
            try:
                ws.cell(ri, ci, float(v) if isinstance(v, (int, float)) else v)
            except Exception:
                ws.cell(ri, ci, str(v))
    # Column widths heuristic
    widths = {"SKU": 14, "Name": 38, "Category": 22, "Launch": 12}
    for ci, name in enumerate(cols_list, 1):
        from openpyxl.utils import get_column_letter
        ws.column_dimensions[get_column_letter(ci)].width = widths.get(name, 14)
    ws.freeze_panes = "A4"
    ws.auto_filter.ref = f"A3:{get_column_letter(len(cols_list))}{3 + len(view)}"
    wb.save(buf)
    st.download_button(
        "⬇️ Download Excel",
        buf.getvalue(),
        file_name=f"npd_tracker_{cur_y}w{cur_w:02d}.xlsx",
        mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )


with st.sidebar:
    st.title("📊 Polleo Planning")
    st.caption("Demand + Supply · v4.0")

    # ── MODULE SWITCHER ──
    module = st.radio(
        "Module",
        ["demand", "supply", "npd"],
        format_func=lambda m: {"demand": "📊 Demand",
                                 "supply": "📦 Supply",
                                 "npd": "🆕 NPD"}[m],
        horizontal=True,
        key="module",
    )

    cy, cw = get_current_cw()
    st.metric("Current week", f"CW{cw}", f"Forecast → CW{cw+1}")
    st.divider()

    if module == "demand":
        # ── DEMAND: OPERATIONS ──
        with st.expander("⚙️ **Operations**", expanded=True):
            if st.button("📥 Update sales", use_container_width=True, key="nav_update"):
                st.session_state["page"] = "update_sales"
            if st.button("🚀 Run forecast", use_container_width=True, key="nav_forecast"):
                st.session_state["page"] = "run_forecast"
            if st.button("📨 KAM/CM inputs", use_container_width=True, key="nav_kam"):
                st.session_state["page"] = "kam_inputs"
            if st.button("📸 Consensus plan", use_container_width=True, key="nav_consensus"):
                st.session_state["page"] = "consensus"
            if st.button("⬇️ Download", use_container_width=True, key="nav_download"):
                st.session_state["page"] = "download"

        # ── DEMAND: PROJECTIONS ──
        with st.expander("📈 **Projections**", expanded=True):
            if st.button("📋 Demand planning", use_container_width=True, key="nav_demand"):
                st.session_state["page"] = "demand_planning"
            if st.button("💰 Revenue", use_container_width=True, key="nav_revenue"):
                st.session_state["page"] = "revenue"
            if st.button("🎯 Forecast accuracy", use_container_width=True, key="nav_accuracy"):
                st.session_state["page"] = "accuracy"
            if st.button("👁️ Top 30 watchlist", use_container_width=True, key="nav_watchlist"):
                st.session_state["page"] = "watchlist"
            if st.button("📑 S&OP meeting", use_container_width=True, key="nav_sop"):
                st.session_state["page"] = "sop_meeting"

        # ── DEMAND: SETTINGS ──
        with st.expander("🔧 **Settings**", expanded=False):
            if st.button("🔧 SKU list", use_container_width=True, key="nav_sku"):
                st.session_state["page"] = "sku_list"
            if st.button("🏪 VP input", use_container_width=True, key="nav_vp"):
                st.session_state["page"] = "vp_input"
            if st.button("🛍️ MP input", use_container_width=True, key="nav_mp"):
                st.session_state["page"] = "mp_input"
            if st.button("📅 ERP promos", use_container_width=True, key="nav_erp"):
                st.session_state["page"] = "erp_promo"

    elif module == "npd":
        # ── NPD: New Product Development ──
        with st.expander("🆕 **NPD pipeline**", expanded=True):
            if st.button("📤 Upload / Add SKUs", use_container_width=True, key="nnav_up"):
                st.session_state["page"] = "npd_upload"
            if st.button("📋 NPD list & sales tracking", use_container_width=True, key="nnav_list"):
                st.session_state["page"] = "npd_list"

    else:  # module == "supply"
        # ── SUPPLY: WORKFLOW ──
        with st.expander("📦 **Workflow**", expanded=True):
            if st.button("📊 Dashboard", use_container_width=True, key="snav_dash"):
                st.session_state["page"] = "supply_dashboard"
            if st.button("📈 Stock projection", use_container_width=True, key="snav_proj"):
                st.session_state["page"] = "supply_projection"
            if st.button("🎯 Scenario planner", use_container_width=True, key="snav_scen"):
                st.session_state["page"] = "supply_scenarios"
            if st.button("📋 Coverage table", use_container_width=True, key="snav_cov"):
                st.session_state["page"] = "supply_coverage"
            if st.button("🚨 Reorder alerts", use_container_width=True, key="snav_alerts"):
                st.session_state["page"] = "supply_alerts"
            if st.button("🏥 Inventory health", use_container_width=True, key="snav_health"):
                st.session_state["page"] = "supply_health"
            if st.button("🏪 Store overstock", use_container_width=True, key="snav_store_ov"):
                st.session_state["page"] = "supply_store_overstock"
            if st.button("✏️ Order entry", use_container_width=True, key="snav_orders"):
                st.session_state["page"] = "supply_orders"
            if st.button("📥 Download supply plan", use_container_width=True, key="snav_dl"):
                st.session_state["page"] = "supply_download"
            if st.button("📊 Coverage workbook", use_container_width=True, key="snav_covwb"):
                st.session_state["page"] = "supply_coverage_wb"

        # ── SUPPLY: SETTINGS ──
        with st.expander("🔧 **Settings**", expanded=False):
            if st.button("📤 Upload data", use_container_width=True, key="snav_up"):
                st.session_state["page"] = "supply_upload"
            if st.button("📦 MOQ", use_container_width=True, key="snav_moq"):
                st.session_state["page"] = "supply_moq"
            if st.button("🚚 Logistics", use_container_width=True, key="snav_logistics"):
                st.session_state["page"] = "supply_logistics"
            if st.button("💶 Cost prices", use_container_width=True, key="snav_costs"):
                st.session_state["page"] = "supply_costs"
            if st.button("⚙️ Safety stock params", use_container_width=True, key="snav_settings"):
                st.session_state["page"] = "supply_settings"

    # ── SHARED: DATA FILES STATUS ──
    with st.expander("📁 **Data files**", expanded=False):
        status = file_status()
        st.caption("**Demand**")
        for f in REQUIRED_CSV:
            st.caption(f"{'✅' if status[f] else '❌'} {f}")
        st.caption(f"{'✅' if status['planning_book'] else '❌'} Planning Book")
        plan_list = (DATA_DIR / "sku_plan_list.csv").exists()
        st.caption(f"{'✅' if plan_list else '⚪'} sku_plan_list.csv {'(active)' if plan_list else '(will init from Book)'}")
        erp_ok = (DATA_DIR / "erp_promo_calendar.csv").exists()
        st.caption(f"{'✅' if erp_ok else '⚪'} erp_promo_calendar.csv {'(active)' if erp_ok else '(optional)'}")
        st.caption(f"{'✅' if status['forecast'] else '❌'} Forecast output")
        n_snaps = len(list(CONSENSUS_DIR.glob("snapshot_*.json")))
        st.caption(f"{'✅' if n_snaps else '⚪'} {n_snaps} consensus snapshot(s)")

        st.caption("**Supply**")
        for label, fname in [("Stock", "stock.csv"),
                             ("Incoming POs", "incoming_supply.csv"),
                             ("Supply master", "supply_master.csv"),
                             ("Forecast bridge", "forecast_for_supply.csv")]:
            ok = (DATA_DIR / fname).exists()
            st.caption(f"{'✅' if ok else '⚪'} {fname}")


# Default page — routed by module
DEMAND_PAGES = {
    "dashboard", "update_sales", "run_forecast", "demand_planning",
    "vp_input", "mp_input", "revenue", "sku_list", "kam_inputs",
    "download", "accuracy", "watchlist", "consensus", "sop_meeting",
    "erp_promo",
}
SUPPLY_PAGES = {
    "supply_dashboard", "supply_projection", "supply_scenarios",
    "supply_coverage", "supply_alerts",
    "supply_orders", "supply_download", "supply_upload", "supply_settings",
    "supply_moq", "supply_logistics", "supply_costs", "supply_health",
    "supply_store_overstock",
    "supply_coverage_wb",
}
NPD_PAGES = {"npd_upload", "npd_list"}

if "page" not in st.session_state:
    st.session_state["page"] = "demand_planning"

# Switching modules routes to that module's default page
if module == "demand" and st.session_state["page"] in (SUPPLY_PAGES | NPD_PAGES):
    st.session_state["page"] = "demand_planning"
elif module == "supply" and st.session_state["page"] in (DEMAND_PAGES | NPD_PAGES):
    st.session_state["page"] = "supply_dashboard"
elif module == "npd" and st.session_state["page"] in (DEMAND_PAGES | SUPPLY_PAGES):
    st.session_state["page"] = "npd_list"

# Route
current_page = st.session_state["page"]

# ── DEMAND routes ──
if current_page == "dashboard":
    page_demand_planning()
elif current_page == "update_sales":
    page_update_sales()
elif current_page == "run_forecast":
    page_run_forecast()
elif current_page == "demand_planning":
    page_demand_planning()
elif current_page == "vp_input":
    page_input("Demand Input VP", "VP (wholesale)", "vp")
elif current_page == "mp_input":
    page_input("Demand Input MP", "MP (marketing/retail)", "mp")
elif current_page == "revenue":
    page_revenue()
elif current_page == "sku_list":
    page_sku_management()
elif current_page == "kam_inputs":
    page_kam_inputs()
elif current_page == "download":
    page_download()
elif current_page == "accuracy":
    page_forecast_accuracy()
elif current_page == "watchlist":
    page_top30_watchlist()
elif current_page == "consensus":
    page_consensus_plan()
elif current_page == "sop_meeting":
    page_sop_meeting()
elif current_page == "erp_promo":
    page_erp_promo()

# ── SUPPLY routes ──
elif current_page == "supply_dashboard":
    page_supply_dashboard()
elif current_page == "supply_projection":
    page_supply_projection()
elif current_page == "supply_scenarios":
    page_supply_scenarios()
elif current_page == "supply_coverage":
    page_supply_coverage()
elif current_page == "supply_alerts":
    page_supply_alerts()
elif current_page == "supply_orders":
    page_supply_order_entry()
elif current_page == "supply_download":
    page_supply_download()
elif current_page == "supply_upload":
    page_supply_upload()
elif current_page == "supply_moq":
    page_supply_moq()
elif current_page == "supply_logistics":
    page_supply_logistics()
elif current_page == "supply_costs":
    page_supply_costs()
elif current_page == "supply_health":
    page_supply_inventory_health()
elif current_page == "supply_store_overstock":
    page_supply_store_overstock()
elif current_page == "supply_settings":
    page_supply_settings()
elif current_page == "supply_coverage_wb":
    page_supply_coverage_workbook()

# ── NPD routes ──
elif current_page == "npd_upload":
    page_npd_upload()
elif current_page == "npd_list":
    page_npd_list()
