"""Cannibalization analysis: NextGen Protein 2 kg promo (POL12880 Vanilla
+ POL12881 Cookies & Cream) vs. its sibling pool.

Compares CW19/CW20 2026 actual sales (RCM channel only — wholesale
excluded per request) against the 12-week pre-promo baseline of
retail + webshop sales from sales_clean.csv.

Output:  docs/NextGen_Cannibalization_CW19-20.xlsx
Run:     py analyze_nextgen_promo.py
"""
from __future__ import annotations

from collections import defaultdict
from datetime import datetime
from pathlib import Path

import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
import pandas as pd

ROOT = Path(__file__).parent
DATA = ROOT / "data"
# Three ERP exports — HR (main), Austria, Slovenia. Schemas differ
# slightly (column indices for SKU / qty / rev are different), so each
# has its own column-map declared below.
SOURCES = [
    {
        "name": "HR",
        "file": DATA / "Rekapitulacijazaakciju.xlsx",
        "sku_col":  5,   # 0-indexed: col 6  "Artikal"
        "qty_col":  29,  # col 30 "Količina"
        "rev_col":  37,  # col 38 "Vrijednost €"
        "date_col": 1,
        "dok_col":  0,
    },
    {
        "name": "AT",
        "file": DATA / "akcijaaut.xlsx",
        "sku_col":  9,   # col 10 "Artikal"
        "qty_col":  45,  # col 46 "Količina"
        "rev_col":  53,  # col 54 "Vrijednost EUR"
        "date_col": 1,
        "dok_col":  0,
    },
    {
        "name": "SLO",
        "file": DATA / "akcijaslo.xlsx",
        "sku_col":  9,
        "qty_col":  45,
        "rev_col":  53,
        "date_col": 1,
        "dok_col":  0,
    },
]
OUT = ROOT / "docs" / "NextGen_Cannibalization_CW19-20.xlsx"
OUT.parent.mkdir(parents=True, exist_ok=True)

# ============================================================
# SKU universe
# ============================================================
PROMO_SKUS = [
    ("POL12880", "Polleo NextGen Protein 2 kg Vanilla"),
    ("POL12881", "Polleo NextGen Protein 2 kg Cookies & Cream"),
]
# Sibling pool from the planner cannibalization screenshot
SIBLINGS = [
    ("POL09747", "Polleo 1st Whey 2.27kg Vanilla Madagascar"),
    ("POL09746", "Polleo 1st Whey 2.27kg Swiss Chocolate Supreme"),
    ("ON00243",  "ON Gold Whey 2.28kg Vanilla Ice Cream"),
    ("ON00298",  "ON Gold Whey 2.28kg French Vanilla Creme"),
    ("POL09901", "Polleo 1st Whey 2.27kg White Choco Bueno"),
    ("POL09750", "Polleo 1st Whey 2.27kg Unflavoured"),
    ("POL09748", "Polleo 1st Whey 2.27kg Cookies & Cream"),
    ("POL09900", "Polleo 1st Whey 2.27kg Chocolate-Hazelnut"),
    ("POL09899", "Polleo 1st Whey 2.27kg Chocolate-Banana"),
]
PROMO_PERIOD_YWS = [202619, 202620]
BASELINE_LAST_N = 20         # target clean weeks (up from 12)
BASELINE_LOOKBACK = 52       # look back up to 1 year to collect them
OOS_FLOOR_MULT = 0.30        # weeks below 30% of initial median = OOS, dropped

# ============================================================
# Load reference data
# ============================================================
plan = pd.read_csv(DATA / "sku_plan_list.csv")
prices = pd.read_csv(DATA / "sku_prices.csv")
costs = pd.read_csv(DATA / "sku_costs.csv")
sales = pd.read_csv(DATA / "sales_clean.csv")
sales["yw"] = sales["year"].astype(int) * 100 + sales["week"].astype(int)

# ERP promo calendar — authoritative source of "this SKU was on promo".
# sales_clean's is_any_promo flag misses some campaigns (e.g. "Akcije
# 03/26" on POL09747/46 was unmasked), so we union both signals.
erp_promo_df = pd.read_csv(DATA / "erp_promo_calendar.csv")
erp_promo_df["yw"] = erp_promo_df["year"].astype(int) * 100 + erp_promo_df["week"].astype(int)
ERP_PROMO_YWS = {
    (str(r["sku"]), int(r["yw"]))
    for _, r in erp_promo_df[erp_promo_df["is_erp_promo"] == 1].iterrows()
}
# Post-promo "bleed" window — 2 weeks after each promo ends often still
# carries elevated baseline (forward-buy effect during promo dampens
# subsequent demand). Drop those too.
ERP_BLEED_YWS = set()
for (sku, yw) in ERP_PROMO_YWS:
    for offset in (1, 2):
        bleed_yw = yw + offset
        if (sku, bleed_yw) not in ERP_PROMO_YWS:
            ERP_BLEED_YWS.add((sku, bleed_yw))

DISC_PCT_PROMO_THRESHOLD = 5.0   # |retail_discount_pct| or |web_discount_pct| above this → treat as promo

price_map = dict(zip(prices["sku"].astype(str),
                       pd.to_numeric(prices["normal_retail_ppp"],
                                     errors="coerce").fillna(0)))
avg_sell_map = dict(zip(prices["sku"].astype(str),
                          pd.to_numeric(prices["avg_sell_price"],
                                        errors="coerce").fillna(0)))
cost_map = dict(zip(costs["sku"].astype(str),
                      pd.to_numeric(costs["cost_price"],
                                    errors="coerce").fillna(0)))
name_map = dict(zip(plan["sku"].astype(str), plan["name"]))
cat_map  = dict(zip(plan["sku"].astype(str), plan["cat"]))
tier_map = dict(zip(plan["sku"].astype(str), plan["oznaka"]))


def _is_clean_week(sku: str, row) -> bool:
    """A clean week = no ERP-calendar promo, no bleed window, no
    is_any_promo flag, AND no absolute discount > threshold on either
    retail or webshop channel."""
    yw = int(row["yw"])
    if (sku, yw) in ERP_PROMO_YWS:
        return False
    if (sku, yw) in ERP_BLEED_YWS:
        return False
    if int(row.get("is_any_promo", 0) or 0):
        return False
    rd = abs(float(row.get("retail_discount_pct", 0) or 0))
    wd = abs(float(row.get("webshop_discount_pct", 0) or 0))
    if rd > DISC_PCT_PROMO_THRESHOLD or wd > DISC_PCT_PROMO_THRESHOLD:
        return False
    return True


def _rcm_web_qty(row) -> float:
    """Retail + webshop (no wholesale). Mirrors the channels we sum
    from the Excel actuals (RCM + WSA/B/C)."""
    return float(row.get("qty_retail", 0) or 0) + float(row.get("qty_webshop", 0) or 0)


def baseline_12w(sku: str) -> dict:
    """Return {avg, median, n, weeks, excluded_reasons} from up to
    BASELINE_LAST_N CLEAN weeks of RETAIL + WEB sales (no wholesale)
    before the promo window. Cleanness:
      - NOT in ERP promo calendar for this SKU
      - NOT in 2-week post-promo bleed window
      - sales_clean is_any_promo == 0
      - |retail_discount_pct| ≤ 5% AND |webshop_discount_pct| ≤ 5%
      - NOT a zero-sale or near-zero week (OOS suspect)
    Looks back up to BASELINE_LOOKBACK weeks to collect them."""
    sub = sales[(sales["sku"] == sku)
                & (sales["yw"] < min(PROMO_PERIOD_YWS))]
    sub = sub.sort_values("yw", ascending=False).head(BASELINE_LOOKBACK)

    candidate_rows = []
    excluded = {"erp_promo": 0, "bleed": 0, "flag": 0,
                "discount_anomaly": 0, "oos": 0}
    for _, r in sub.iterrows():
        yw = int(r["yw"])
        if (sku, yw) in ERP_PROMO_YWS:
            excluded["erp_promo"] += 1
            continue
        if (sku, yw) in ERP_BLEED_YWS:
            excluded["bleed"] += 1
            continue
        if int(r.get("is_any_promo", 0) or 0):
            excluded["flag"] += 1
            continue
        rd = abs(float(r.get("retail_discount_pct", 0) or 0))
        wd = abs(float(r.get("webshop_discount_pct", 0) or 0))
        if rd > DISC_PCT_PROMO_THRESHOLD or wd > DISC_PCT_PROMO_THRESHOLD:
            excluded["discount_anomaly"] += 1
            continue
        candidate_rows.append(r)
        if len(candidate_rows) >= BASELINE_LAST_N * 2:
            break

    # OOS filter: drop zero-sale weeks AND weeks below 30% of
    # provisional median (likely stockout / sharp-drop).
    if candidate_rows:
        rcm_web_qtys = [_rcm_web_qty(r) for r in candidate_rows]
        prov_med = pd.Series(rcm_web_qtys).median()
        threshold = max(1.0, OOS_FLOOR_MULT * prov_med)
        clean = []
        for r in candidate_rows:
            q = _rcm_web_qty(r)
            if q <= 0 or q < threshold:
                excluded["oos"] += 1
                continue
            clean.append(r)
            if len(clean) >= BASELINE_LAST_N:
                break
    else:
        clean = []

    qty = pd.Series([_rcm_web_qty(r) for r in clean])
    weeks = [(int(r["yw"]), _rcm_web_qty(r)) for r in clean]
    return {
        "avg": float(qty.mean()) if len(qty) else 0.0,
        "median": float(qty.median()) if len(qty) else 0.0,
        "n": int(len(qty)),
        "weeks": sorted(weeks),
        "excluded": excluded,
    }


# ============================================================
# Pull actuals from HR + AT + SLO Excels (RCM + WSA/B/C channels)
# ============================================================
# Polleo document codes — confirmed by CM:
#   RCM       = retail (physical store sales)
#   WSA/B/C   = WEB sales (different web sub-channels, NOT wholesale)
# Wholesale is in a separate ERP export we don't have here, so it's
# naturally excluded just by using these Excel files.
ALL_CHANNELS = ("RCM", "WSA", "WSB", "WSC")
act_qty = defaultdict(lambda: defaultdict(float))    # sku -> yw -> qty
act_rev = defaultdict(lambda: defaultdict(float))    # actual € booked
# Also break out by source country for traceability in the output
act_qty_by_src = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))  # sku -> src -> yw -> qty

ALL_SKUS = {s for s, _ in PROMO_SKUS} | {s for s, _ in SIBLINGS}

for src in SOURCES:
    p = src["file"]
    if not p.exists():
        print(f"  [skip] {src['name']}: {p.name} not found.")
        continue
    wb_raw = openpyxl.load_workbook(p, data_only=True, read_only=True)
    ws_raw = wb_raw.active
    n_matched = 0
    for row in ws_raw.iter_rows(min_row=2, values_only=True):
        dok = str(row[src["dok_col"]] or "").upper()
        if not any(dok.startswith(pf) for pf in ALL_CHANNELS):
            continue
        dt = row[src["date_col"]]
        sku = row[src["sku_col"]]
        if sku not in ALL_SKUS:
            continue
        if not isinstance(dt, datetime):
            continue
        iso = dt.isocalendar()
        yw = iso[0] * 100 + iso[1]
        qty = float(row[src["qty_col"]] or 0)
        rev = float(row[src["rev_col"]] or 0)
        act_qty[sku][yw] += qty
        act_rev[sku][yw] += rev
        act_qty_by_src[sku][src["name"]][yw] += qty
        n_matched += 1
    wb_raw.close()
    print(f"  [{src['name']}] {p.name}: {n_matched:,} matching rows added.")

# ============================================================
# Compose rows for both 2-week and 1-week (CW20 only) views
# ============================================================
def build_rows(period_yws: list[int], label: str) -> list[dict]:
    """Build per-SKU row dicts for a given promo period (list of yws).
    Returns list ordered PROMO first then SIBLINGS."""
    out = []
    n_weeks = len(period_yws)
    for sku, name_fallback in PROMO_SKUS + SIBLINGS:
        role = "PROMO" if any(s == sku for s, _ in PROMO_SKUS) else "SIBLING"
        bl = baseline_12w(sku)
        base_avg = bl["avg"]
        n_clean = bl["n"]

        actual_qty = sum(act_qty[sku].get(yw, 0.0) for yw in period_yws)
        actual_rev = sum(act_rev[sku].get(yw, 0.0) for yw in period_yws)

        list_price = price_map.get(sku, 0.0)
        avg_price = avg_sell_map.get(sku, 0.0)
        if avg_price <= 0:
            avg_price = list_price
        cost = cost_map.get(sku, 0.0)

        baseline_rev_wk = base_avg * avg_price
        baseline_ruc_wk = base_avg * max(0.0, avg_price - cost)

        # Effective price during period
        eff_price = (actual_rev / actual_qty) if actual_qty > 0 else 0.0
        actual_ruc = actual_qty * max(0.0, eff_price - cost)

        expected_qty = base_avg * n_weeks
        expected_rev = baseline_rev_wk * n_weeks
        expected_ruc = baseline_ruc_wk * n_weeks

        delta_qty = actual_qty - expected_qty
        delta_rev = actual_rev - expected_rev
        delta_ruc = actual_ruc - expected_ruc
        drop_pct = (delta_qty / expected_qty * 100) if expected_qty else 0.0

        out.append({
            "role": role, "sku": sku,
            "name": name_map.get(sku, name_fallback),
            "tier": tier_map.get(sku, ""),
            "n_clean": n_clean,
            "baseline_avg_wk": base_avg,
            "list_price": list_price,
            "avg_sell_price": avg_price,
            "cost": cost,
            "expected_qty": expected_qty,
            "expected_rev": expected_rev,
            "expected_ruc": expected_ruc,
            "actual_qty": actual_qty,
            "actual_rev": actual_rev,
            "actual_ruc": actual_ruc,
            "eff_price": eff_price,
            "delta_qty": delta_qty,
            "delta_rev": delta_rev,
            "delta_ruc": delta_ruc,
            "drop_pct": drop_pct,
            "baseline_weeks": bl["weeks"],
            "cw19_qty": act_qty[sku].get(202619, 0.0),
            "cw20_qty": act_qty[sku].get(202620, 0.0),
            "label": label,
            "n_weeks": n_weeks,
        })
    return out


rows_2w = build_rows([202619, 202620], "2-week (CW19+CW20)")
rows_1w = build_rows([202620],         "1-week (CW20 — current)")


# ============================================================
# Write Excel with two sheets: Summary + Weekly detail
# ============================================================
THIN = Side(border_style="thin", color="CCCCCC")
HEADER_FILL = PatternFill("solid", fgColor="0C2340")
ALT_FILL = PatternFill("solid", fgColor="F1F4F8")
PROMO_FILL = PatternFill("solid", fgColor="FFE9D5")
WIN_FONT = Font(bold=True, color="10B981")
NEG_FONT = Font(bold=True, color="DC2626")


def write_header(ws, headers, row):
    for c, h in enumerate(headers, start=1):
        cell = ws.cell(row, c, h)
        cell.font = Font(bold=True, color="FFFFFF")
        cell.fill = HEADER_FILL
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
        cell.border = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)


def fmt_eur(v):
    return f"{v:,.0f} €" if v else "—"


def fmt_int(v):
    return f"{int(round(v)):,}" if v else "0"


wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Summary"

# Top banner
ws.merge_cells("A1:Q1")
title_cell = ws.cell(1, 1, "NextGen Protein 2 kg Promo — Cannibalization Analysis (CW19–CW20 2026)")
title_cell.font = Font(bold=True, size=14, color="FFFFFF")
title_cell.fill = HEADER_FILL
title_cell.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 28

ws.cell(2, 1, "Baseline = MEAN of last 12 non-promo weeks (qty_total = retail + webshop + wholesale from sales_clean.csv).  "
                "Actual = ALL channels (RCM + WSA/B/C) from Excel export.").font = Font(italic=True, color="666666")
ws.row_dimensions[2].height = 18
ws.merge_cells("A2:Q2")


def render_block(start_row: int, block_rows: list[dict], block_title: str) -> int:
    """Render one period block (2-week or 1-week) starting at `start_row`.
    Returns the row index AFTER the block (with 1 empty row spacing)."""
    n_w = block_rows[0]["n_weeks"] if block_rows else 0
    # Section header
    ws.merge_cells(start_row=start_row, start_column=1, end_row=start_row, end_column=17)
    h = ws.cell(start_row, 1, f"📊  {block_title}  ·  period span: {n_w} week(s)")
    h.font = Font(bold=True, size=12, color="FFFFFF")
    h.fill = PatternFill("solid", fgColor="1F3A5F")
    h.alignment = Alignment(horizontal="left", vertical="center", indent=1)
    ws.row_dimensions[start_row].height = 22

    # Column headers
    headers = [
        "Role", "SKU", "Name", "Tier",
        "Baseline avg/wk", "List €", "Avg sell €",
        f"Expected {n_w}w qty", f"Actual {n_w}w qty", "Δ qty", "Drop %",
        f"Expected {n_w}w rev €", f"Actual {n_w}w rev €", "Δ rev €",
        f"Expected {n_w}w RUC €", f"Actual {n_w}w RUC €", "Δ RUC €",
    ]
    write_header(ws, headers, start_row + 1)

    row = start_row + 2
    for r in block_rows:
        fill = PROMO_FILL if r["role"] == "PROMO" else (ALT_FILL if row % 2 == 0 else None)
        values = [
            r["role"], r["sku"], r["name"], r["tier"],
            round(r["baseline_avg_wk"], 1),
            round(r["list_price"], 2), round(r["avg_sell_price"], 2),
            round(r["expected_qty"], 1), round(r["actual_qty"], 1),
            round(r["delta_qty"], 1), round(r["drop_pct"], 1),
            round(r["expected_rev"], 0), round(r["actual_rev"], 0), round(r["delta_rev"], 0),
            round(r["expected_ruc"], 0), round(r["actual_ruc"], 0), round(r["delta_ruc"], 0),
        ]
        for c, v in enumerate(values, start=1):
            cell = ws.cell(row, c, v)
            if fill: cell.fill = fill
            cell.border = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
            if c == 1:
                cell.font = Font(bold=True,
                                 color=("E8734A" if r["role"] == "PROMO" else "1F3A5F"))
            if c in (10, 11, 14, 17):    # delta + drop %
                try:
                    num = float(v)
                    if num > 0: cell.font = WIN_FONT
                    elif num < 0: cell.font = NEG_FONT
                except Exception:
                    pass
            if c in (5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17):
                cell.alignment = Alignment(horizontal="right")
            if c in (12, 13, 14, 15, 16, 17):
                cell.number_format = "#,##0 €"
        row += 1

    # Totals
    totals_row = row
    ws.cell(totals_row, 1, "TOTAL")
    def sm(f):
        return sum(r[f] for r in block_rows)
    def smr(f, role):
        return sum(r[f] for r in block_rows if r["role"] == role)
    ws.cell(totals_row, 8,  round(sm("expected_qty"), 1))
    ws.cell(totals_row, 9,  round(sm("actual_qty"), 1))
    ws.cell(totals_row, 10, round(sm("delta_qty"), 1))
    ws.cell(totals_row, 12, round(sm("expected_rev"), 0))
    ws.cell(totals_row, 13, round(sm("actual_rev"), 0))
    ws.cell(totals_row, 14, round(sm("delta_rev"), 0))
    ws.cell(totals_row, 15, round(sm("expected_ruc"), 0))
    ws.cell(totals_row, 16, round(sm("actual_ruc"), 0))
    ws.cell(totals_row, 17, round(sm("delta_ruc"), 0))
    for c in range(1, 18):
        cc = ws.cell(totals_row, c)
        cc.font = Font(bold=True, color="FFFFFF")
        cc.fill = HEADER_FILL
        cc.border = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
        if c in (8, 9, 10, 12, 13, 14, 15, 16, 17):
            cc.alignment = Alignment(horizontal="right")
        if c in (12, 13, 14, 15, 16, 17):
            cc.number_format = "#,##0 €"

    # Net by role
    nr_row = totals_row + 2
    ws.cell(nr_row, 1, "Net by role").font = Font(bold=True, size=11, color="0C2340")
    ws.cell(nr_row + 1, 1, "PROMO Δ rev").font = Font(bold=True)
    ws.cell(nr_row + 1, 2, round(smr("delta_rev", "PROMO"), 0)).number_format = "#,##0 €"
    ws.cell(nr_row + 1, 3, "PROMO Δ RUC").font = Font(bold=True)
    ws.cell(nr_row + 1, 4, round(smr("delta_ruc", "PROMO"), 0)).number_format = "#,##0 €"
    ws.cell(nr_row + 2, 1, "SIBLINGS Δ rev").font = Font(bold=True)
    ws.cell(nr_row + 2, 2, round(smr("delta_rev", "SIBLING"), 0)).number_format = "#,##0 €"
    ws.cell(nr_row + 2, 3, "SIBLINGS Δ RUC").font = Font(bold=True)
    ws.cell(nr_row + 2, 4, round(smr("delta_ruc", "SIBLING"), 0)).number_format = "#,##0 €"
    nx = nr_row + 3
    net_r = smr("delta_rev", "PROMO") + smr("delta_rev", "SIBLING")
    net_u = smr("delta_ruc", "PROMO") + smr("delta_ruc", "SIBLING")
    ws.cell(nx, 1, "NET category Δ rev").font = Font(bold=True)
    nc = ws.cell(nx, 2, round(net_r, 0))
    nc.number_format = "#,##0 €"; nc.font = WIN_FONT if net_r >= 0 else NEG_FONT
    ws.cell(nx, 3, "NET category Δ RUC").font = Font(bold=True)
    nc2 = ws.cell(nx, 4, round(net_u, 0))
    nc2.number_format = "#,##0 €"; nc2.font = WIN_FONT if net_u >= 0 else NEG_FONT

    return nx + 3   # blank rows before next block


next_row = render_block(4, rows_2w, "2-week period (CW19 + CW20) — full promo so far")
next_row = render_block(next_row, rows_1w, "1-week period (CW20 — current week only)")

# Caveat about partial week
ws.cell(next_row + 1, 1, "⚠️  CW20 may be a partial week if today's date is mid-week. Drop % can look exaggerated for that reason."
       ).font = Font(italic=True, color="DC2626")
ws.merge_cells(start_row=next_row + 1, start_column=1, end_row=next_row + 1, end_column=17)

# Column widths
widths = [10, 12, 50, 11, 14, 9, 11, 15, 16, 9, 9, 17, 17, 12, 17, 17, 12]
for i, w in enumerate(widths, start=1):
    ws.column_dimensions[get_column_letter(i)].width = w
ws.freeze_panes = "C5"

# ---------- Sheet 2: Weekly detail ----------
ws2 = wb.create_sheet("Weekly detail")
ws2.merge_cells("A1:R1")
title2 = ws2.cell(1, 1, "Weekly detail — sales_clean baseline (qty_total) vs CW19/CW20 actuals (RCM + WSA/B/C)")
title2.font = Font(bold=True, size=12, color="FFFFFF")
title2.fill = HEADER_FILL
title2.alignment = Alignment(horizontal="center")

# Build week columns: last 12 baseline weeks + CW19 + CW20
baseline_yws = set()
for r in rows_2w:
    for (yw, _) in r["baseline_weeks"]:
        baseline_yws.add(yw)
baseline_yws = sorted(baseline_yws)
all_weeks = baseline_yws + [202619, 202620]

headers2 = ["Role", "SKU", "Name"] + [f"CW{yw % 100}/{yw // 100}" for yw in all_weeks] + ["Baseline avg", "CW19+20 total"]
write_header(ws2, headers2, 3)

row = 4
for r in rows_2w:
    fill = PROMO_FILL if r["role"] == "PROMO" else None
    weeks_dict = dict(r["baseline_weeks"])
    ws2.cell(row, 1, r["role"]).font = Font(bold=True,
        color=("E8734A" if r["role"] == "PROMO" else "1F3A5F"))
    ws2.cell(row, 2, r["sku"])
    ws2.cell(row, 3, r["name"])
    col = 4
    for yw in baseline_yws:
        ws2.cell(row, col, round(weeks_dict.get(yw, 0), 1))
        col += 1
    ws2.cell(row, col, round(r["cw19_qty"], 1))
    ws2.cell(row, col + 1, round(r["cw20_qty"], 1))
    col += 2
    ws2.cell(row, col, round(r["baseline_avg_wk"], 1)).font = Font(bold=True)
    ws2.cell(row, col + 1, round(r["actual_qty"], 1)).font = Font(bold=True)
    for c in range(1, col + 2):
        cell = ws2.cell(row, c)
        if fill: cell.fill = fill
        cell.border = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
        if c >= 4:
            cell.alignment = Alignment(horizontal="right")
    row += 1

ws2.column_dimensions["A"].width = 10
ws2.column_dimensions["B"].width = 12
ws2.column_dimensions["C"].width = 45
for i in range(4, 4 + len(all_weeks) + 2):
    ws2.column_dimensions[get_column_letter(i)].width = 11
ws2.freeze_panes = "D4"

# Save
def _safe_save(wb, path: Path) -> Path:
    try:
        wb.save(path)
        return path
    except PermissionError:
        i = 1
        while True:
            alt = path.with_name(f"{path.stem}_v{i}{path.suffix}")
            if not alt.exists():
                break
            i += 1
        wb.save(alt)
        return alt


written = _safe_save(wb, OUT)
print(f"Generated: {written}  ({written.stat().st_size // 1024} KB)")

# Console mini-summary — 2-week block
def _print_block(rs, label):
    print()
    print(f"=== {label} ===")
    print(f"{'SKU':10s}  {'Role':8s}  {'Baseline/wk':>11s}  "
          f"{'Actual':>7s}  {'Drop %':>7s}  {'Δ rev €':>11s}  {'Δ RUC €':>11s}")
    print("-" * 80)
    for r in rs:
        print(f"{r['sku']:10s}  {r['role']:8s}  {r['baseline_avg_wk']:>11.1f}  "
              f"{r['actual_qty']:>7.0f}  {r['drop_pct']:>6.1f}%  "
              f"{r['delta_rev']:>11,.0f}  {r['delta_ruc']:>11,.0f}")
    print("-" * 80)
    print(f"{'TOTAL':10s}  {'':8s}  {'':>11s}  {sum(r['actual_qty'] for r in rs):>7.0f}  "
          f"{'':>7s}  {sum(r['delta_rev'] for r in rs):>11,.0f}  "
          f"{sum(r['delta_ruc'] for r in rs):>11,.0f}")


_print_block(rows_2w, "2-week (CW19 + CW20) — all channels")
_print_block(rows_1w, "1-week (CW20 only — current week)")
