from __future__ import annotations

import argparse
import json
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any

from PIL import Image
import yaml
from tableau_export import build_export_image_js, normalize_image_size


NPX_EXECUTABLE = shutil.which("npx.cmd") or shutil.which("npx") or "npx"
PLAYWRIGHT_CMD = [NPX_EXECUTABLE, "--yes", "--package", "@playwright/cli", "playwright-cli"]
SESSION_NAME = "vip-report-monthly-sales"
VIEWPORT = {"width": 1400, "height": 3400}


MONTH_CN_MAP = {
    1: "一月",
    2: "二月",
    3: "三月",
    4: "四月",
    5: "五月",
    6: "六月",
    7: "七月",
    8: "八月",
    9: "九月",
    10: "十月",
    11: "十一月",
    12: "十二月",
}
ORDERED_MONTHS = [MONTH_CN_MAP[index] for index in range(1, 13)]
CAPTURE_EXECUTION_ORDER = [
    # S02 fixed two-step download order requested by product flow:
    # 1) Overview (after applying filters) -> download image
    # 2) StoreSalesinDetail -> download image
    "overview",
    "store_sales_detail",
    # S03 dependencies keep their natural order afterwards.
    "overview_s03",
    "store_kpi_lfl",
]


def normalize_month_label(raw_month: Any) -> str:
    """Normalize report month input to the Chinese month label used by the report."""
    if raw_month is None:
        return MONTH_CN_MAP[1]
    if isinstance(raw_month, int):
        return MONTH_CN_MAP.get(raw_month, MONTH_CN_MAP[1])

    text = str(raw_month).strip()
    if text in ORDERED_MONTHS:
        return text
    match = re.fullmatch(r"0?([1-9]|1[0-2])(?:月)?", text)
    if match:
        return MONTH_CN_MAP[int(match.group(1))]
    return text


def resolve_report_period(args: argparse.Namespace, config: dict[str, Any]) -> tuple[str, int, int]:
    """Resolve the report month/year inputs from CLI args and config."""
    report_cfg = config.get("report", {})
    report_month = normalize_month_label(args.report_month or report_cfg.get("month_cn", MONTH_CN_MAP[1]))
    report_year = int(args.report_year or report_cfg.get("year", 2026))
    report_compare_year = int(args.compare_year or report_cfg.get("compare_year", report_year - 1))
    return report_month, report_year, report_compare_year


def log_progress(scope: str, message: str) -> None:
    """Print a compact progress line for long-running stages."""
    print(f"[{scope}] {message}", flush=True)


def log_timing(label: str, started_at: float) -> None:
    """Print a compact timing line."""
    elapsed_seconds = time.monotonic() - started_at
    print(f"[timing] {label}: {elapsed_seconds:.1f}s", flush=True)


def build_capture_slide_codes(filtered_assets: list[dict[str, Any]]) -> dict[str, list[str]]:
    """Map each capture_id to the slide codes that depend on it."""
    capture_slide_codes: dict[str, set[str]] = {}
    for asset in filtered_assets:
        capture_slide_codes.setdefault(asset["capture_id"], set()).add(asset["slide_code"])
    return {capture_id: sorted(slide_codes) for capture_id, slide_codes in capture_slide_codes.items()}


def describe_capture(
    capture_id: str,
    capture_spec: dict[str, Any],
    capture_slide_codes: dict[str, list[str]],
) -> str:
    """Build a readable page label for logging."""
    slide_codes = "/".join(capture_slide_codes.get(capture_id, [])) or "-"
    return f"{slide_codes} {capture_id} ({capture_spec['activate_sheet']})"


def build_cumulative_month_values(report_month: str) -> list[str]:
    """Build cumulative month labels up to the report month."""
    if report_month not in ORDERED_MONTHS:
        return [report_month]
    index = ORDERED_MONTHS.index(report_month)
    return ORDERED_MONTHS[: index + 1]


def resolve_month_index(report_month: str) -> int:
    """Resolve the normalized month label into a month index."""
    normalized = normalize_month_label(report_month)
    if normalized not in ORDERED_MONTHS:
        return 1
    return ORDERED_MONTHS.index(normalized) + 1


def build_month_value_variants(report_month: str, *, cumulative: bool) -> list[list[str]]:
    """Build robust Tableau month filter value candidates."""
    month_index = resolve_month_index(report_month)
    month_indexes = list(range(1, month_index + 1)) if cumulative else [month_index]
    month_labels = [normalize_month_label(index) for index in month_indexes]
    return [
        [str(index) for index in month_indexes],
        [f"{index:02d}" for index in month_indexes],
        month_labels,
    ]


def resolve_month_values(report_month: str, month_mode: str) -> list[str]:
    """Resolve single-month or cumulative month filters."""
    if month_mode == "single":
        return [report_month]
    return build_cumulative_month_values(report_month)


def build_specs(report_month: str, report_year: int, compare_year: int, month_mode: str) -> dict[str, Any]:
    """Build the Tableau capture and crop specs for monthly sales slides."""
    # S02/S03 monthly sales always use cumulative months from January to the report month.
    cumulative_month_value_candidates = build_month_value_variants(report_month, cumulative=True)
    s03_month_values = build_cumulative_month_values(report_month)
    summary_strip_crop = {"left": 0, "top": 528, "width": 1383, "height": 332}
    common_monthly_filters = [
        {
            "label": "year",
            "field_candidates": ["Year", "年(daily)"],
            "value_candidates": [[str(report_year)]],
            "apply_all_fields": True,
            "target_scope": "active_sheet",
        },
        {
            "label": "month",
            "field_candidates": ["Month", "月(daily)"],
            "value_candidates": cumulative_month_value_candidates,
            "apply_all_fields": True,
            "target_scope": "active_sheet",
        },
        {
            "label": "store",
            "field_candidates": ["store", "Storename (group)", "Storename"],
            "value_candidates": [["CKC-VIP"], ["ckc-vip"]],
            "apply_all_fields": True,
            "target_scope": "active_sheet",
        },
        {"field": "Sales Type", "values": ["GMV"], "target_scope": "active_sheet"},
        {"field": "Brand", "values": ["CK"], "target_scope": "active_sheet"},
    ]
    # S02 overview needs both current-year and comparison-year measures, so it must not
    # apply a hard Year filter that would exclude year2-backed values like Sales2/LFL.
    s02_overview_filters = [item for item in common_monthly_filters if item.get("label") != "year"]
    return {
        "captures": [
            {
                "capture_id": "overview",
                "session": SESSION_NAME,
                "hash_url": "#/views/ECMonthlySalesReport_17250004228820/Overview?:iid=2",
                "inner_frame_fragment": "/views/ECMonthlySalesReport_17250004228820/Overview?",
                "activate_sheet": "Overview",
                "filters": [*s02_overview_filters],
                "params": {},
                "raw_screenshot_name": "monthly-sales-overview.png",
                "note": "S02 overview dashboard export.",
            },
            {
                "capture_id": "overview_s03",
                "session": SESSION_NAME,
                "hash_url": "#/views/ECMonthlySalesReport_17250004228820/Overview?:iid=2",
                "inner_frame_fragment": "/views/ECMonthlySalesReport_17250004228820/Overview?",
                "activate_sheet": "Overview",
                "filters": [
                    {"field": "Brand", "values": ["CK"]},
                    {"field": "Sales Type", "values": ["GMV"]},
                    {"field": "Storename (group)", "values": ["CKC-VIP"]},
                    {"field": "Storename", "values": ["CKC-VIP"]},
                    {"field": "Month", "values": s03_month_values},
                    {"field": "月(daily)", "values": s03_month_values},
                ],
                "params": {"year1": report_year, "year2": compare_year},
                "raw_screenshot_name": "monthly-sales-overview-s03.png",
                "note": "S03 overview dashboard export.",
            },
            {
                "capture_id": "store_sales_detail",
                "session": SESSION_NAME,
                "hash_url": "#/views/ECMonthlySalesReport_17250004228820/StoreSalesinDetail?:iid=2",
                "inner_frame_fragment": "/views/ECMonthlySalesReport_17250004228820/StoreSalesinDetail?",
                "activate_sheet": "Store Sales in Detail",
                "filters": [*common_monthly_filters],
                "params": {},
                "raw_screenshot_name": "monthly-sales-store-sales-detail.png",
                "note": "S02 detail dashboard export.",
            },
            {
                "capture_id": "store_kpi_lfl",
                "session": SESSION_NAME,
                "hash_url": "#/views/ECMonthlySalesReport_17250004228820/StoreKPILFL?:iid=2",
                "inner_frame_fragment": "/views/ECMonthlySalesReport_17250004228820/StoreKPILFL?",
                "activate_sheet": "Store KPI LFL",
                "filters": [
                    {"field": "Brand", "values": ["CK"]},
                    {"field": "Sales Type", "values": ["GMV"]},
                    {"field": "Storename", "values": ["CKC-VIP"]},
                    {"field": "Year", "values": [str(report_year)]},
                    {"field": "Month", "values": [report_month]},
                    {"field": "月(daily)", "values": [report_month]},
                ],
                "params": {"year1": report_year, "year2": compare_year},
                "raw_screenshot_name": "monthly-sales-store-kpi-lfl.png",
                "note": "S03 store KPI export.",
            },
        ],
        "assets": [
            {
                "slide_code": "S02",
                "slide": 2,
                "shape_name": "鍥剧墖 4",
                "shape_id": 5,
                "asset_name": "s02_monthly_sales_chart",
                "capture_id": "overview",
                "crop": {"left": 12, "top": 510, "width": 1360, "height": 383},
                "resize_to": {"width": 1106, "height": 319},
                "source_view": "Overview",
                "note": "S02 top chart.",
            },
            {
                "slide_code": "S02",
                "slide": 2,
                "shape_name": "鍥剧墖 6",
                "shape_id": 7,
                "asset_name": "s02_monthly_sales_overview_table",
                "capture_id": "overview",
                "crop": {"left": 55, "top": 140, "width": 1330, "height": 311},
                "resize_to": {"width": 1097, "height": 278},
                "source_view": "Overview",
                "note": "S02 middle table.",
            },
            {
                "slide_code": "S02",
                "slide": 2,
                "shape_name": "鍥剧墖 7",
                "shape_id": 8,
                "asset_name": "s02_monthly_sales_summary_strip",
                "capture_id": "store_sales_detail",
                "crop": {"left": 0, "top": 460, "width": 1380, "height": 354},
                "resize_to": {"width": 1155, "height": 71},
                "source_view": "Store Sales in Detail",
                "note": "S02 summary strip.",
            },
            {
                "slide_code": "S03",
                "slide": 3,
                "shape_name": "鍥剧墖 1",
                "shape_id": 2,
                "asset_name": "s03_kpi_lfl_quad",
                "capture_id": "overview_s03",
                "source_view": "Overview",
                "note": "S03 four-panel composite.",
                "composite": {
                    "canvas": {"width": 1110, "height": 621, "background": "#FFFFFF"},
                    "panels": [
                        {
                            "panel_code": "gp",
                            "crop": {"left": 0, "top": 1610, "width": 650, "height": 400},
                            "dest": {"left": 0, "top": 0, "width": 542, "height": 297},
                        },
                        {
                            "panel_code": "con",
                            "crop": {"left": 650, "top": 1610, "width": 650, "height": 400},
                            "dest": {"left": 568, "top": 0, "width": 542, "height": 297},
                        },
                        {
                            "panel_code": "atv",
                            "crop": {"left": 0, "top": 2000, "width": 650, "height": 360},
                            "dest": {"left": 0, "top": 324, "width": 542, "height": 297},
                        },
                        {
                            "panel_code": "returnqty",
                            "crop": {"left": 650, "top": 2000, "width": 650, "height": 360},
                            "dest": {"left": 568, "top": 324, "width": 542, "height": 297},
                        },
                    ],
                },
            },
        ],
    }
def run_cmd(
    args: list[str],
    *,
    cwd: Path,
    timeout: int = 120,
    check: bool = True,
) -> subprocess.CompletedProcess[str]:
    """Helper."""
    return subprocess.run(
        args,
        cwd=str(cwd),
        text=True,
        encoding="utf-8",
        errors="replace",
        capture_output=True,
        timeout=timeout,
        check=check,
    )


def run_playwright(args: list[str], *, cwd: Path, timeout: int = 120) -> str:
    """Helper."""
    result = run_cmd(PLAYWRIGHT_CMD + args, cwd=cwd, timeout=timeout)
    return result.stdout


def write_js(workdir: Path, name: str, content: str) -> Path:
    """Helper."""
    path = workdir / name
    path.write_text(content, encoding="utf-8")
    return path


def build_login_js(username: str, password: str) -> str:
    """Helper."""
    payload = {
        "username": username,
        "password": password,
    }
    spec = json.dumps(payload, ensure_ascii=False)
    return f"""async function(page) {{
  const spec = {spec};
  if (!page.url().includes('/#/signin')) {{
    return {{ url: page.url(), skipped: true }};
  }}

  const inputs = page.locator('input');
  const username = inputs.nth(0);
  const password = inputs.nth(1);
  const button = page.locator('button').nth(0);
  await username.waitFor({{ state: 'visible', timeout: 15000 }});
  await username.fill(spec.username);
  await password.fill(spec.password);
  await Promise.all([
    page.waitForFunction(() => !location.href.includes('/#/signin'), null, {{ timeout: 30000 }}).catch(() => null),
    button.click(),
  ]);
  await page.waitForTimeout(3000);
  return {{ url: page.url(), title: await page.title() }};
}}
"""


def build_configure_view_js(spec: dict[str, Any]) -> str:
    """Helper."""
    payload = json.dumps(spec, ensure_ascii=False)
    return f"""async function(page) {{
  const spec = {payload};
  await page.waitForFunction(
    () => !!(window.tableau && window.tableau.VizManager && window.tableau.VizManager.getVizs().length),
    null,
    {{ timeout: 30000 }}
  );

  return await page.evaluate(async (config) => {{
    const viz = window.tableau.VizManager.getVizs()[0];
    const workbook = viz.getWorkbook();
    try {{
      await workbook.revertAllAsync();
    }} catch (error) {{
    }}
    await workbook.activateSheetAsync(config.activate_sheet);
    const activeSheet = workbook.getActiveSheet();
    let worksheets = [];
    if (activeSheet && typeof activeSheet.getWorksheets === 'function') {{
      worksheets = activeSheet.getWorksheets();
    }}
    // 鍚屾椂瑕嗙洊 dashboard 鏈綋涓庡唴閮?worksheets锛岄伩鍏嶉儴鍒嗗瓧娈靛彧鍦ㄥ叾涓€涓婄敓鏁堛€?
    const targets = [];
    if (activeSheet) {{
      targets.push(activeSheet);
    }}
    for (const worksheet of worksheets) {{
      if (!targets.includes(worksheet)) {{
        targets.push(worksheet);
      }}
    }}
    const updateType = window.tableau.FilterUpdateType.REPLACE;
    const filterApply = [];

    for (const filter of config.filters) {{
      let applied = false;
      for (const worksheet of targets) {{
        try {{
          await worksheet.applyFilterAsync(filter.field, filter.values, updateType);
          applied = true;
          filterApply.push({{
            field: filter.field,
            worksheet: typeof worksheet.getName === 'function' ? worksheet.getName() : 'unknown',
            ok: true,
          }});
        }} catch (error) {{
        }}
      }}
      if (!applied) {{
        filterApply.push({{
          field: filter.field,
          worksheet: null,
          ok: false,
        }});
      }}
    }}

    for (const [name, value] of Object.entries(config.params)) {{
      try {{
        await workbook.changeParameterValueAsync(name, value);
      }} catch (error) {{
      }}
    }}

    await new Promise((resolve) => setTimeout(resolve, 7000));
    return {{
      activeSheet: activeSheet.getName(),
      targetCount: targets.length,
      filterApply,
      filters: config.filters,
      params: config.params,
      url: location.href,
    }};
  }}, spec);
}}
"""


def build_configure_view_js_v2(spec: dict[str, Any]) -> str:
    """Helper."""
    payload = json.dumps(spec, ensure_ascii=False)
    return f"""async function(page) {{
  const spec = {payload};
  await page.waitForLoadState('domcontentloaded').catch(() => null);
  await page.waitForTimeout(3000);

  async function inspectFrame(frame, preferredFragment) {{
    const frameUrl = typeof frame.url === 'function' ? frame.url() : null;
    try {{
      const snapshot = await frame.evaluate(() => {{
        const vizCount = window.tableau?.VizManager?.getVizs?.().length || 0;
        return {{
          url: location.href,
          title: document.title,
          readyState: document.readyState,
          hasTableau: !!(window.tableau && window.tableau.VizManager),
          vizCount,
        }};
      }});
      return {{
        ...snapshot,
        isMainFrame: frame === page.mainFrame(),
        matchesTargetFrame: !!(preferredFragment && frameUrl && frameUrl.includes(preferredFragment)),
      }};
    }} catch (error) {{
      return {{
        url: frameUrl,
        title: null,
        readyState: null,
        hasTableau: false,
        vizCount: 0,
        isMainFrame: frame === page.mainFrame(),
        matchesTargetFrame: !!(preferredFragment && frameUrl && frameUrl.includes(preferredFragment)),
        error: String(error && error.message || error),
      }};
    }}
  }}

  async function locateVizFrame(timeoutMs, preferredFragment) {{
    const deadline = Date.now() + timeoutMs;
    let lastSnapshots = [];
    while (Date.now() < deadline) {{
      const rankedFrames = page.frames()
        .map((frame) => {{
          const frameUrl = typeof frame.url === 'function' ? frame.url() : '';
          const isPreferred = !!(preferredFragment && frameUrl.includes(preferredFragment));
          return {{
            frame,
            rank: isPreferred ? 0 : (frame === page.mainFrame() ? 1 : 2),
          }};
        }})
        .sort((left, right) => left.rank - right.rank);

      lastSnapshots = [];
      for (const item of rankedFrames) {{
        const snapshot = await inspectFrame(item.frame, preferredFragment);
        lastSnapshots.push(snapshot);
        if (snapshot.vizCount > 0) {{
          return {{ frame: item.frame, snapshots: lastSnapshots }};
        }}
      }}

      const stillOnSignin =
        (page.url() || '').includes('/#/signin') ||
        lastSnapshots.some((snapshot) => (snapshot.url || '').includes('/#/signin'));
      if (stillOnSignin) {{
        throw new Error('Tableau page is still on sign-in: ' + JSON.stringify({{ pageUrl: page.url(), frames: lastSnapshots }}));
      }}

      await page.waitForTimeout(2000);
    }}

    throw new Error('Timed out waiting for Tableau viz: ' + JSON.stringify({{ pageUrl: page.url(), frames: lastSnapshots }}));
  }}

  const located = await locateVizFrame(Number(spec.tableau_wait_ms || 90000), spec.inner_frame_fragment || '');
  const result = await located.frame.evaluate(async (config) => {{
    const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
    const deadline = Date.now() + Number(config.workbook_wait_ms || 60000);
    let viz = null;
    let workbook = null;

    while (Date.now() < deadline) {{
      try {{
        const vizs = window.tableau?.VizManager?.getVizs?.() || [];
        viz = vizs[0] || null;
        workbook = viz && typeof viz.getWorkbook === 'function' ? viz.getWorkbook() : null;
      }} catch (error) {{
        workbook = null;
      }}
      if (workbook) {{
        break;
      }}
      await sleep(500);
    }}

    if (!workbook) {{
      throw new Error('Tableau workbook is not ready: ' + JSON.stringify({{
        url: location.href,
        title: document.title,
        readyState: document.readyState,
        vizCount: window.tableau?.VizManager?.getVizs?.().length || 0,
      }}));
    }}

    try {{
      await workbook.revertAllAsync();
    }} catch (error) {{
    }}

    await workbook.activateSheetAsync(config.activate_sheet);
    const activeSheet = workbook.getActiveSheet();
    let worksheets = [];
    if (activeSheet && typeof activeSheet.getWorksheets === 'function') {{
      worksheets = activeSheet.getWorksheets();
    }}

    const targets = [];
    if (activeSheet) {{
      targets.push(activeSheet);
    }}
    for (const worksheet of worksheets) {{
      if (!targets.includes(worksheet)) {{
        targets.push(worksheet);
      }}
    }}

    const updateType = window.tableau.FilterUpdateType.REPLACE;
    const filterApply = [];

    function normalizeWorksheetName(name) {{
      return String(name || '').trim().toLowerCase();
    }}

    function resolveSelectedTargets(filter) {{
      let selectedTargets =
        filter.target_scope === 'active_sheet' && activeSheet ? [activeSheet] : targets;
      const worksheetNames = Array.isArray(filter.worksheet_names) ? filter.worksheet_names : [];
      if (worksheetNames.length > 0) {{
        const allowedWorksheetNames = new Set(
          worksheetNames.map((name) => normalizeWorksheetName(name)).filter(Boolean)
        );
        selectedTargets = selectedTargets.filter((worksheet) => {{
          const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : '';
          return allowedWorksheetNames.has(normalizeWorksheetName(worksheetName));
        }});
      }}
      return selectedTargets;
    }}

    async function applyFilterCandidate(field, values, selectedTargets) {{
      let anySuccess = false;
      for (const worksheet of targets) {{
        const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : 'unknown';
        try {{
          if (!selectedTargets.includes(worksheet)) {{
            continue;
          }}
          await worksheet.applyFilterAsync(field, values, updateType);
          anySuccess = true;
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: true,
          }});
        }} catch (error) {{
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: false,
            error: String(error && error.message || error),
          }});
        }}
      }}
      return anySuccess;
    }}

    for (const filter of config.filters || []) {{
      const fieldCandidates = filter.field_candidates || (filter.field ? [filter.field] : []);
      const valueCandidates = filter.value_candidates || (filter.values ? [filter.values] : []);
      const applyAllFields = !!filter.apply_all_fields;
      const selectedTargets = resolveSelectedTargets(filter);
      let matched = false;

      for (const field of fieldCandidates) {{
        let fieldMatched = false;
        for (const values of valueCandidates) {{
          const result = await applyFilterCandidate(field, values, selectedTargets);
          if (result) {{
            fieldMatched = true;
            matched = true;
            break;
          }}
        }}
        if (fieldMatched && !applyAllFields) {{
          break;
        }}
      }}

      if (!matched) {{
        filterApply.push({{
          field: filter.label || fieldCandidates.join('/'),
          worksheet: null,
          ok: false,
          required: filter.required !== false,
        }});
        if (filter.required !== false) {{
          throw new Error(`Required filter '${{filter.label || fieldCandidates.join('/')}}' did not match any worksheet.`);
        }}
      }}
    }}

    for (const [name, value] of Object.entries(config.params)) {{
      try {{
        await workbook.changeParameterValueAsync(name, value);
      }} catch (error) {{
      }}
    }}

    await sleep(Number(config.post_filter_wait_ms || 10000));
    return {{
      activeSheet: activeSheet.getName(),
      targetCount: targets.length,
      filterApply,
      filters: config.filters,
      params: config.params,
      url: location.href,
    }};
  }}, {{
    activate_sheet: spec.activate_sheet,
    filters: spec.filters,
    params: spec.params,
    workbook_wait_ms: spec.workbook_wait_ms || 60000,
    post_filter_wait_ms: spec.post_filter_wait_ms || 10000,
  }});

  return {{
    ...result,
    frameSnapshots: located.snapshots,
  }};
}}
"""


def build_capture_js(inner_frame_fragment: str, screenshot_name: str) -> str:
    """Helper."""
    payload = json.dumps(
        {
            "inner_frame_fragment": inner_frame_fragment,
            "screenshot_name": screenshot_name,
        },
        ensure_ascii=False,
    )
    return f"""async function(page) {{
  const spec = {payload};
  await page.waitForTimeout(3000);
  const frame = page.frames().find(
    (candidate) => candidate !== page.mainFrame() && candidate.url().includes(spec.inner_frame_fragment)
  );
  const target = frame || page;
  const body = await target.$('body');
  await body.screenshot({{
    path: spec.screenshot_name,
    scale: 'css',
  }});
  return {{
    frameUrl: frame ? frame.url() : null,
    pageUrl: page.url(),
    screenshotName: spec.screenshot_name,
  }};
}}
"""


def run_code(session: str, script_path: Path, *, cwd: Path, timeout: int = 120) -> str:
    """Helper."""
    return run_playwright(
        ["--session", session, "run-code", "--filename", str(script_path)],
        cwd=cwd,
        timeout=timeout,
    )


def ensure_browser_session(session: str, *, cwd: Path) -> None:
    """Helper."""
    run_playwright(["--session", session, "open", "about:blank"], cwd=cwd, timeout=60)


def save_state(session: str, state_path: Path, *, cwd: Path) -> None:
    """Helper."""
    state_path.parent.mkdir(parents=True, exist_ok=True)
    run_playwright(
        ["--session", session, "state-save", str(state_path)],
        cwd=cwd,
        timeout=60,
    )


def load_state_if_present(session: str, state_path: Path, *, cwd: Path) -> None:
    """Helper."""
    if not state_path.exists():
        return
    run_playwright(
        ["--session", session, "state-load", str(state_path)],
        cwd=cwd,
        timeout=60,
    )


def locate_session_file(root: Path, session: str, filename: str) -> Path:
    """Helper."""
    direct = root / "output" / "playwright" / session / filename
    if direct.exists():
        return direct

    search_roots = [
        root / "output" / "playwright",
        root / "output" / "vip-report",
        root,
    ]
    matches: list[Path] = []
    for search_root in search_roots:
        if search_root.exists():
            matches.extend(search_root.rglob(filename))

    if len(matches) == 1:
        return matches[0]
    if matches:
        latest = sorted({item.resolve() for item in matches}, key=lambda item: item.stat().st_mtime, reverse=True)[0]
        return latest
    raise FileNotFoundError(f"Unable to locate {filename} under {root}.")


def crop_image(
    source: Path,
    target: Path,
    crop: dict[str, int],
    *,
    resize_to: dict[str, int] | None = None,
) -> None:
    """Helper."""
    image = Image.open(source)
    box = (
        crop["left"],
        crop["top"],
        crop["left"] + crop["width"],
        crop["top"] + crop["height"],
    )
    result = image.crop(box)
    if resize_to:
        result = result.resize((resize_to["width"], resize_to["height"]), Image.Resampling.LANCZOS)
    target.parent.mkdir(parents=True, exist_ok=True)
    result.save(target)


def compose_panels(
    source: Path,
    target: Path,
    composite: dict[str, Any],
) -> list[dict[str, Any]]:
    """Helper."""
    image = Image.open(source).convert("RGB")
    canvas_spec = composite["canvas"]
    canvas = Image.new("RGB", (canvas_spec["width"], canvas_spec["height"]), canvas_spec.get("background", "#FFFFFF"))
    panel_records: list[dict[str, Any]] = []

    for panel in composite["panels"]:
        crop = panel["crop"]
        dest = panel["dest"]
        box = (
            crop["left"],
            crop["top"],
            crop["left"] + crop["width"],
            crop["top"] + crop["height"],
        )
        cropped = image.crop(box)
        resized = cropped.resize((dest["width"], dest["height"]), Image.Resampling.LANCZOS)
        canvas.paste(resized, (dest["left"], dest["top"]))
        panel_records.append(
            {
                "panel_code": panel["panel_code"],
                "crop": crop,
                "dest": dest,
            }
        )

    target.parent.mkdir(parents=True, exist_ok=True)
    canvas.save(target)
    return panel_records


def parse_args() -> argparse.Namespace:
    """Helper."""
    parser = argparse.ArgumentParser(description="Capture monthly sales Tableau assets for VIP report slides S02/S03.")
    parser.add_argument(
        "--config",
        default=r"C:\Users\niuniu\.codex\vip-report\config.yaml",
        help="Path to VIP report config.yaml",
    )
    parser.add_argument(
        "--slides",
        default="S02,S03",
        help="Comma-separated slide codes to generate, default: S02,S03",
    )
    parser.add_argument(
        "--report-month",
        default="",
        help="Report month label for Tableau filter, e.g. 浜屾湀 / 2 / 02",
    )
    parser.add_argument(
        "--report-year",
        type=int,
        default=0,
        help="Report year for Tableau filter, e.g. 2026",
    )
    parser.add_argument(
        "--compare-year",
        type=int,
        default=0,
        help="Comparison year for year2 parameter, e.g. 2025",
    )
    parser.add_argument(
        "--month-mode",
        choices=["cumulative", "single"],
        default="cumulative",
        help="Month filter mode: cumulative (default) or single",
    )
    parser.add_argument(
        "--single-month",
        action="store_true",
        help="Shortcut for --month-mode single",
    )
    parser.add_argument(
        "--output-dir",
        default="",
        help="Override the output/work directory used for generated assets and ops.",
    )
    return parser.parse_args()


def collect_required_capture_ids(filtered_assets: list[dict[str, Any]]) -> set[str]:
    """Helper."""
    return {asset["capture_id"] for asset in filtered_assets}


def build_capture_execution_order(required_capture_ids: set[str]) -> list[str]:
    """Build deterministic capture order with S02 two-step download first."""
    ordered = [capture_id for capture_id in CAPTURE_EXECUTION_ORDER if capture_id in required_capture_ids]
    remaining = sorted(required_capture_ids - set(ordered))
    return ordered + remaining


def build_tableau_target_url(base_url: str, hash_url: str) -> str:
    """Build Tableau hash-view URL in canonical /#/views/... style."""
    normalized_hash_url = hash_url.strip()
    if normalized_hash_url.startswith("#"):
        return f"{base_url.rstrip('/')}/{normalized_hash_url}"
    return f"{base_url.rstrip('/')}/{normalized_hash_url.lstrip('/')}"


def goto_with_retry(session: str, target_url: str, *, cwd: Path, attempts: int = 3) -> None:
    """Helper."""
    last_error: subprocess.CalledProcessError | None = None
    for attempt in range(1, attempts + 1):
        try:
            run_playwright(["--session", session, "goto", target_url], cwd=cwd, timeout=120)
            return
        except subprocess.CalledProcessError as error:
            last_error = error
            if attempt >= attempts:
                raise
            time.sleep(2)
    if last_error is not None:
        raise last_error


def capture_tableau_view(
    capture_spec: dict[str, Any],
    *,
    base_url: str,
    session: str,
    workdir: Path,
    workspace_root: Path,
) -> Path:
    """Helper."""
    target_url = build_tableau_target_url(base_url, capture_spec["hash_url"])
    goto_with_retry(session, target_url, cwd=workdir)
    run_playwright(
        ["--session", session, "resize", str(VIEWPORT["width"]), str(VIEWPORT["height"])],
        cwd=workdir,
        timeout=60,
    )

    configure_script = write_js(
        workdir,
        f"tmp-{capture_spec['capture_id']}-configure.js",
        build_configure_view_js_v2(
            {
                "activate_sheet": capture_spec["activate_sheet"],
                "filters": capture_spec["filters"],
                "params": capture_spec["params"],
                "inner_frame_fragment": capture_spec["inner_frame_fragment"],
            }
        ),
    )
    capture_script = write_js(
        workdir,
        f"tmp-{capture_spec['capture_id']}-capture.js",
        build_export_image_js(
            capture_spec["inner_frame_fragment"],
            str((workdir / capture_spec["raw_screenshot_name"]).resolve()),
            viewport=VIEWPORT,
        ),
    )

    try:
        max_attempts = 5
        last_error: str | None = None
        for attempt in range(1, max_attempts + 1):
            configure_output = run_code(session, configure_script, cwd=workdir, timeout=420).strip()
            if "### Error" in configure_output:
                last_error = configure_output
                if attempt < max_attempts:
                    # Tableau 鍋跺彂 workbook 灏氭湭灏辩华锛屽洖鍒板悓椤靛悗閲嶈瘯鍙仮澶嶃€?
                    run_playwright(["--session", session, "goto", target_url], cwd=workdir, timeout=120)
                    run_playwright(
                        ["--session", session, "resize", str(VIEWPORT["width"]), str(VIEWPORT["height"])],
                        cwd=workdir,
                        timeout=60,
                    )
                    time.sleep(2)
                    continue
                raise RuntimeError(
                    f"Failed to configure Tableau view for {capture_spec['capture_id']}: {last_error}"
                )
            run_code(session, capture_script, cwd=workdir, timeout=180)
            break
    finally:
        configure_script.unlink(missing_ok=True)
        capture_script.unlink(missing_ok=True)

    local_output = workdir / capture_spec["raw_screenshot_name"]
    if local_output.exists():
        normalize_image_size(local_output, {"width": 1400, "height": 3360})
        return local_output
    captured_path = locate_session_file(workspace_root, session, capture_spec["raw_screenshot_name"])
    normalize_image_size(captured_path, {"width": 1400, "height": 3360})
    return captured_path


def main() -> None:
    """Helper."""
    script_started_at = time.monotonic()
    args = parse_args()
    config_path = Path(args.config)
    config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    report_month, report_year, compare_year = resolve_report_period(args, config)
    month_mode = "single" if args.single_month else args.month_mode
    specs = build_specs(report_month, report_year, compare_year, month_mode)

    requested = {item.strip().upper() for item in args.slides.split(",") if item.strip()}
    filtered_assets = [item for item in specs["assets"] if item["slide_code"] in requested]
    if not filtered_assets:
        raise SystemExit("No matching slides requested.")

    vip_workdir = Path(args.output_dir).resolve() if args.output_dir else Path(config["paths"]["workdir"]).resolve()
    workspace_root = vip_workdir if args.output_dir else vip_workdir.parents[1]
    asset_dir = vip_workdir / "assets" / "monthly-sales"
    data_dir = vip_workdir / "data" / "monthly-sales"
    asset_dir.mkdir(parents=True, exist_ok=True)
    data_dir.mkdir(parents=True, exist_ok=True)

    captures_by_id = {item["capture_id"]: item for item in specs["captures"]}
    required_capture_ids = collect_required_capture_ids(filtered_assets)
    capture_sequence = build_capture_execution_order(required_capture_ids)
    capture_slide_codes = build_capture_slide_codes(filtered_assets)

    log_progress("stage", f"start monthly-sales slides={','.join(sorted(requested))}")

    session = SESSION_NAME if not args.output_dir else f"{SESSION_NAME}-{vip_workdir.name}"
    state_path = (
        vip_workdir / ".playwright-cli" / f"{session}-state.json"
        if args.output_dir
        else workspace_root / "output" / "playwright" / session / "state.json"
    )

    ensure_browser_session(session, cwd=vip_workdir)
    load_state_if_present(session, state_path, cwd=vip_workdir)

    base_url = config["tableau"]["base_url"].rstrip("/")
    first_capture = captures_by_id[capture_sequence[0]]
    first_target_url = build_tableau_target_url(base_url, first_capture["hash_url"])
    goto_with_retry(session, first_target_url, cwd=vip_workdir)

    login_script = write_js(
        vip_workdir,
        "tmp-monthly-sales-login.js",
        build_login_js(config["tableau"]["username"], config["tableau"]["password"]),
    )
    try:
        run_code(session, login_script, cwd=vip_workdir, timeout=120)
    finally:
        login_script.unlink(missing_ok=True)

    raw_screenshots: dict[str, Path] = {}
    # Stage 1: download all required raw screenshots first (S02: overview -> store_sales_detail).
    for capture_id in capture_sequence:
        capture_spec = captures_by_id[capture_id]
        page_label = describe_capture(capture_id, capture_spec, capture_slide_codes)
        page_started_at = time.monotonic()
        log_progress("page", f"start {page_label}")
        raw_screenshots[capture_id] = capture_tableau_view(
            capture_spec,
            base_url=base_url,
            session=session,
            workdir=vip_workdir,
            workspace_root=workspace_root,
        )
        log_timing(f"page {page_label}", page_started_at)

    save_state(session, state_path, cwd=vip_workdir)

    operations = {"replace_text": [], "replace_images": []}
    manifest_items: list[dict[str, Any]] = []
    # Stage 2: crop/composite all assets after downloads are completed.
    assets_started_at = time.monotonic()
    for asset in filtered_assets:
        asset_label = f"{asset['slide_code']} {asset['asset_name']}"
        asset_started_at = time.monotonic()
        log_progress("asset", f"start {asset_label}")
        target = asset_dir / f"{asset['asset_name']}.png"
        source_path = raw_screenshots[asset["capture_id"]]

        panel_records: list[dict[str, Any]] = []
        if "composite" in asset:
            panel_records = compose_panels(source_path, target, asset["composite"])
        else:
            crop_image(
                source_path,
                target,
                asset["crop"],
                resize_to=asset.get("resize_to"),
            )

        operations["replace_images"].append(
            {
                "slide": asset["slide"],
                "shape_id": asset["shape_id"],
                "shape_name": asset["shape_name"],
                "image_path": str(target),
            }
        )
        manifest_item = {
            "slide_code": asset["slide_code"],
            "slide": asset["slide"],
            "shape_id": asset["shape_id"],
            "shape_name": asset["shape_name"],
            "asset_name": asset["asset_name"],
            "asset_path": str(target),
            "source_capture_id": asset["capture_id"],
            "source_view": asset["source_view"],
            "note": asset["note"],
            "raw_screenshot": str(source_path),
        }
        if "crop" in asset:
            manifest_item["crop"] = asset["crop"]
        if asset.get("resize_to"):
            manifest_item["resize_to"] = asset["resize_to"]
        if panel_records:
            manifest_item["panel_records"] = panel_records
            manifest_item["composite_canvas"] = asset["composite"]["canvas"]
        manifest_items.append(manifest_item)
        log_timing(f"asset {asset_label}", asset_started_at)
    log_timing(f"assets monthly-sales {','.join(sorted(requested))}", assets_started_at)

    operations_path = vip_workdir / "render-ops.monthly-sales.live.json"
    operations_path.write_text(json.dumps(operations, ensure_ascii=False, indent=2), encoding="utf-8")

    manifest_path = data_dir / "monthly-sales-assets.live.json"
    manifest_path.write_text(
        json.dumps(
            {
                "source": {
                    "captures": [
                        {
                            "capture_id": capture_id,
                            "hash_url": captures_by_id[capture_id]["hash_url"],
                            "activate_sheet": captures_by_id[capture_id]["activate_sheet"],
                            "filters": captures_by_id[capture_id]["filters"],
                            "params": captures_by_id[capture_id]["params"],
                            "raw_screenshot": str(raw_screenshots[capture_id]),
                            "note": captures_by_id[capture_id]["note"],
                        }
                        for capture_id in capture_sequence
                    ],
                    "config_path": str(config_path),
                    "report_period": {
                        "month": report_month,
                        "year": report_year,
                        "compare_year": compare_year,
                        "month_mode": month_mode,
                    },
                },
                "assets": manifest_items,
                "operations_path": str(operations_path),
            },
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    log_timing(f"stage monthly-sales {','.join(sorted(requested))}", script_started_at)
    print(json.dumps({"operations_path": str(operations_path), "manifest_path": str(manifest_path)}, ensure_ascii=False))


# 鍏抽敭琛屾敞閲婏細鑴氭湰鐩存帴杩愯鏃讹紝浠庤繖閲岃繘鍏ヤ富娴佺▼銆?
if __name__ == "__main__":
    main()
