from __future__ import annotations

import argparse
import json
import math
import os
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-inventory-monthly"
VIEWPORT = {"width": 1400, "height": 3360}
TEMPLATE_LOCK_DIR = Path(r"C:\workspace\cursor\output\vip-report\assets\template-lock-s04-s08-s13")
PLAYWRIGHT_CLI_CONFIG_PATH = Path(__file__).resolve().parents[1] / ".playwright" / "cli.config.json"
INVENTORY_MONTHLY_DOWNLOAD_POST_FILTER_WAIT_MS = 15000
INVENTORY_MONTHLY_OVERALL_POST_FILTER_WAIT_MS = 20000
INVENTORY_MONTHLY_RENDER_SETTLE_POLL_INTERVAL_MS = 1000
INVENTORY_MONTHLY_RENDER_SETTLE_STABLE_POLLS = 2
INVENTORY_MONTHLY_DOWNLOAD_VALIDATION_RETRY_INTERVAL_MS = 1000
INVENTORY_MONTHLY_OVERALL_MIN_DOWNLOAD_FILE_SIZE_BYTES = 100000
INVENTORY_MONTHLY_OVERALL_DOWNLOAD_VALIDATION_MAX_ATTEMPTS = 8
SINGLE_SLIDE_TIMEOUT_SECONDS = 180
S05_BAGS_WORKSHEET_NAMES = [
    "bags sales lfl",
    "bags sales lfl ttl",
    "bags sales lfl data",
    "bags%",
    "bags% diff",
    "bags asp",
    "bags asp lfl",
    "bags gp",
    "bags gp diff",
    "bags pd",
    "bags pd diff",
]


MONTH_CN_MAP = {
    1: "一月",
    2: "二月",
    3: "三月",
    4: "四月",
    5: "五月",
    6: "六月",
    7: "七月",
    8: "八月",
    9: "九月",
    10: "十月",
    11: "十一月",
    12: "十二月",
}
MONTH_INDEX_MAP = {label: index for index, label in MONTH_CN_MAP.items()}


def should_use_template_lock(report_month: str, report_year: int, compare_year: int) -> bool:
    """Helper."""
    month = normalize_month_label(report_month)
    return (
        report_year == 2026
        and compare_year == 2025
        and month in {"一月", "1", "01"}
        and TEMPLATE_LOCK_DIR.exists()
    )


def normalize_month_label(raw_month: Any) -> str:
    """Helper."""
    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 MONTH_INDEX_MAP:
        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]:
    """Helper."""
    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))
    compare_year = int(args.compare_year or report_cfg.get("compare_year", report_year - 1))
    return report_month, report_year, compare_year


def parse_requested_slides(raw_slides: str) -> set[str]:
    """Helper."""
    return {item.strip().upper() for item in raw_slides.split(",") if item.strip()}


def should_use_single_slide_timeout(requested_slides: set[str]) -> bool:
    """Helper."""
    return len(requested_slides) == 1


def resolve_deadline(timeout_seconds: int | None) -> float | None:
    """Helper."""
    if timeout_seconds is None or timeout_seconds <= 0:
        return None
    return time.monotonic() + timeout_seconds


def remaining_timeout(timeout_seconds: int, *, deadline: float | None, action: str) -> int:
    """Helper."""
    if deadline is None:
        return timeout_seconds
    remaining_seconds = deadline - time.monotonic()
    if remaining_seconds <= 0:
        raise TimeoutError(
            f"Single-slide capture exceeded {SINGLE_SLIDE_TIMEOUT_SECONDS}s while {action}; aborting."
        )
    return max(1, min(timeout_seconds, math.ceil(remaining_seconds)))


def deadline_remaining_seconds(deadline: float | None) -> float | None:
    """Helper."""
    if deadline is None:
        return None
    return max(0.0, deadline - time.monotonic())


def log_timing(label: str, started_at: float, *, deadline: float | None = None) -> None:
    """Print a compact timing line to help diagnose slow Tableau stages."""
    elapsed_seconds = time.monotonic() - started_at
    remaining_seconds = deadline_remaining_seconds(deadline)
    if remaining_seconds is None:
        print(f"[timing] {label}: {elapsed_seconds:.1f}s", flush=True)
    else:
        print(
            f"[timing] {label}: {elapsed_seconds:.1f}s (remaining {remaining_seconds:.1f}s)",
            flush=True,
        )


def log_progress(scope: str, message: str) -> None:
    """Print a compact progress line."""
    print(f"[{scope}] {message}", 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 resolve_month_index(report_month: str) -> int:
    """Helper."""
    normalized = normalize_month_label(report_month)
    if normalized in MONTH_INDEX_MAP:
        return MONTH_INDEX_MAP[normalized]
    match = re.fullmatch(r"0?([1-9]|1[0-2])", str(report_month).strip())
    if match:
        return int(match.group(1))
    return 1


def build_month_value_variants(report_month: str, *, cumulative: bool) -> list[list[str]]:
    """Helper."""
    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(idx) for idx in month_indexes]
    return [
        [str(idx) for idx in month_indexes],
        [f"{idx:02d}" for idx in month_indexes],
        month_labels,
    ]


def build_filters(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
    *,
    include_compare_year: bool = True,
    cumulative_month: bool = True,
    storename_value_candidates: list[list[str]] | None = None,
    storename_field_candidates: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Helper."""
    resolved_compare_year = report_year - 1 if compare_year is None else compare_year
    year_values = (
        [str(resolved_compare_year), str(report_year)]
        if include_compare_year
        else [str(report_year)]
    )
    month_value_variants = build_month_value_variants(report_month, cumulative=cumulative_month)
    storename_candidates = storename_value_candidates or [["CKC-VIP"], ["ckc-vip"]]
    storename_fields = storename_field_candidates or [
        "storename",
        "Storename",
        "storecode",
        "storename (group)",
        "Storename (group)",
    ]

    return [
        {
            "label": "storename",
            "field_candidates": storename_fields,
            "value_candidates": storename_candidates,
            "require_worksheet_match": False,
        },
        {
            "label": "year",
            "field_candidates": ["年(billdate)", "billdate 年", "Year", "year"],
            "value_candidates": [year_values],
            "require_worksheet_match": False,
        },
        {
            "label": "brand",
            "field_candidates": ["Brand (Shoes)", "Brand（Shoes）", "Brand", "brand"],
            "value_candidates": [["CK"]],
            "require_worksheet_match": False,
        },
        {
            "label": "month",
            "field_candidates": ["月(billdate)", "billdate 月", "Month", "month", "月"],
            "value_candidates": month_value_variants,
            "require_worksheet_match": False,
        },
    ]


def build_subcategory_filters(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Helper."""
    filters = build_filters(
        report_month,
        report_year,
        compare_year,
        include_compare_year=True,
        cumulative_month=True,
        storename_value_candidates=[["CKC-VIP"], ["ckc-vip"]],
        storename_field_candidates=[
            "storename",
            "Storename",
            "storecode",
            "storename (group)",
            "Storename (group)",
        ],
    )
    for item in filters:
        if item.get("label") == "brand":
            item["required"] = False

    filters.append(
        {
            "label": "category",
            "field_candidates": [
                "category",
                "Category",
                "category ",
                "Category ",
                "Top Category",
                "top category",
            ],
            "value_candidates": [
                ["BAGS", "ACC", "SHOES"],
                ["BAGS", "SHOES", "ACC"],
                ["bags", "acc", "shoes"],
            ],
            "require_worksheet_match": False,
        }
    )
    return filters


def build_s08_filters(report_month: str, report_year: int) -> list[dict[str, Any]]:
    """S08 works only with the current report year and current report month on live Tableau."""
    month_index = resolve_month_index(report_month)
    return [
        {
            "label": "storename",
            "field_candidates": [
                "storename (组)",
                "Storename (组)",
                "storename",
                "Storename",
                "storecode",
                "storename (group)",
                "Storename (group)",
            ],
            "value_candidates": [["CKC-VIP"], ["ckc-vip"]],
            "require_worksheet_match": False,
        },
        {
            "label": "year",
            "field_candidates": ["年(billdate)", "billdate 年", "Year", "year"],
            "value_candidates": [[str(report_year)]],
            "require_worksheet_match": False,
        },
        {
            "label": "month",
            "field_candidates": ["月(billdate)", "billdate 月", "Month", "month", "月"],
            "value_candidates": [
                [str(month_index)],
                [f"{month_index:02d}"],
                [normalize_month_label(report_month)],
            ],
            "require_worksheet_match": False,
        },
    ]


def build_bags_filters(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Helper."""
    resolved_compare_year = report_year - 1 if compare_year is None else compare_year
    month_value_variants = build_month_value_variants(report_month, cumulative=True)
    year_values = [str(resolved_compare_year), str(report_year)]
    return [
        {
            "label": "year",
            "field_candidates": ["billdate 年", "年(billdate)"],
            "value_candidates": [year_values],
            "require_worksheet_match": False,
            "target_scope": "active_sheet",
            "skip_value_validation": True,
        },
        {
            "label": "month",
            "field_candidates": ["Month", "month", "billdate 月", "月(billdate)"],
            "value_candidates": month_value_variants,
            "require_worksheet_match": False,
            "target_scope": "active_sheet",
            "skip_value_validation": True,
        },
        {
            "label": "storename",
            "field_candidates": [
                "storename",
                "Storename",
            ],
            "value_candidates": [["ckc-vip"], ["CKC-VIP"]],
            "require_worksheet_match": False,
            "target_scope": "active_sheet",
            "skip_value_validation": True,
        },
    ]


def build_s07_filters(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Use a dedicated current-month filter set for S07 only."""
    year_values = [str(report_year)]
    month_value_variants = build_month_value_variants(report_month, cumulative=False)
    return [
        {
            "label": "storename",
            "field_candidates": [
                "storename (组)",
                "Storename (组)",
                "storename",
                "Storename",
                "storecode",
                "storename (group)",
                "Storename (group)",
            ],
            "value_candidates": [["CKC-VIP"]],
            "require_worksheet_match": False,
        },
        {
            "label": "year",
            "field_candidates": ["年(billdate)", "billdate 年", "Year", "year"],
            "value_candidates": [year_values],
            "require_worksheet_match": False,
        },
        {
            "label": "brand",
            "field_candidates": ["Brand (Shoes)", "Brand（Shoes）", "Brand", "brand"],
            "value_candidates": [["CK"]],
            "require_worksheet_match": False,
        },
        {
            "label": "month",
            "field_candidates": ["月(billdate)", "billdate 月", "Month", "month", "月"],
            "value_candidates": month_value_variants,
            "require_worksheet_match": False,
        },
    ]


def normalize_dashboard_download_filters(filters: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Align S04-S08 Tableau filtering with the S05 active-sheet download flow."""
    normalized_filters: list[dict[str, Any]] = []
    for filter_spec in filters:
        normalized_filter = dict(filter_spec)
        normalized_filter["target_scope"] = "active_sheet"
        normalized_filter["require_worksheet_match"] = False
        normalized_filter["skip_value_validation"] = True
        normalized_filters.append(normalized_filter)
    return normalized_filters


def build_shoes_filters(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Helper."""
    filters = build_filters(
        report_month,
        report_year,
        compare_year,
        include_compare_year=True,
        cumulative_month=True,
        storename_value_candidates=[["CKC-VIP"], ["ckc-vip"]],
        storename_field_candidates=[
            "storename (Shoes) ",
            "Storename (Shoes) ",
            "storename (Shoes)",
            "Storename (Shoes)",
            "storename",
            "Storename",
            "storecode",
            "storename (group)",
            "Storename (group)",
        ],
    )
    for item in filters:
        if item.get("label") == "storename":
            item["require_worksheet_match"] = True
    return filters


def build_time_ui_filter_actions(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Apply the visible Month and billdate 年 quick filters through the UI."""
    month_values = [
        normalize_month_label(index)
        for index in range(1, resolve_month_index(report_month) + 1)
    ]
    resolved_compare_year = report_year - 1 if compare_year is None else compare_year
    year_values = [str(resolved_compare_year), str(report_year)]
    return [
        {
            "kind": "quick-filter",
            "label": "Month",
            "values": month_values,
            "expected_button_text": "",
        },
        {
            "kind": "quick-filter",
            "label": "billdate 年",
            "values": year_values,
            "expected_button_text": "",
        },
    ]


def build_overall_ui_filter_actions(
    report_month: str,
    report_year: int,
    compare_year: int | None = None,
) -> list[dict[str, Any]]:
    """Force S04 Overall to re-apply the visible time quick filters through the UI."""
    return build_time_ui_filter_actions(report_month, report_year, compare_year)

def build_specs(report_month: str, report_year: int, compare_year: int) -> dict[str, Any]:
    """Helper."""
    filters = [
        filter_spec
        for filter_spec in normalize_dashboard_download_filters(
            build_filters(report_month, report_year, compare_year)
        )
        if filter_spec.get("label") in {"brand", "storename"}
    ]
    s04_ui_filter_actions = build_overall_ui_filter_actions(
        report_month,
        report_year,
        compare_year,
    )
    bags_shoes_ui_filter_actions = build_time_ui_filter_actions(
        report_month,
        report_year,
        compare_year,
    )
    s05_filters = [
        filter_spec
        for filter_spec in normalize_dashboard_download_filters(
            build_bags_filters(report_month, report_year, compare_year)
        )
        if filter_spec.get("label") == "storename"
    ]
    # S06 intentionally mirrors the S05 Bags flow; only the Tableau page differs.
    s06_filters = [
        filter_spec
        for filter_spec in normalize_dashboard_download_filters(
            build_bags_filters(report_month, report_year, compare_year)
        )
        if filter_spec.get("label") == "storename"
    ]
    s07_filters = normalize_dashboard_download_filters(
        build_s07_filters(report_month, report_year, compare_year)
    )
    s08_filters = build_s08_filters(report_month, report_year)

    capture_template = [
        {
            "capture_id": "overall",
            "hash_url": "#/views/CKInventoryMonthlyReport/Overall?:iid=1",
            "inner_frame_fragment": "/views/CKInventoryMonthlyReport/Overall?",
            "activate_sheet": "Overall",
            "filters": filters,
            "capture_mode": "download_image",
            "post_filter_wait_ms": INVENTORY_MONTHLY_OVERALL_POST_FILTER_WAIT_MS,
            "ui_filter_mode": "script",
            "ui_filter_actions": s04_ui_filter_actions,
            "ui_filter_post_wait_ms": 8000,
            "ui_filter_forbidden_text_patterns": ["当前筛选器没有要可视化的数据"],
            "min_download_file_size_bytes": INVENTORY_MONTHLY_OVERALL_MIN_DOWNLOAD_FILE_SIZE_BYTES,
            "download_validation_retry_interval_ms": INVENTORY_MONTHLY_DOWNLOAD_VALIDATION_RETRY_INTERVAL_MS,
            "download_validation_max_attempts": INVENTORY_MONTHLY_OVERALL_DOWNLOAD_VALIDATION_MAX_ATTEMPTS,
            "normalize_download_size": {"width": 1400, "height": 3320},
            "raw_screenshot_name": "inventory-s04-overall.png",
            "note": "S04 overall dashboard export after applying active-sheet filters.",
        },
        {
            "capture_id": "bags_full",
            "hash_url": "#/views/CKInventoryMonthlyReport/Bags?:iid=1",
            "inner_frame_fragment": "/views/CKInventoryMonthlyReport/Bags?",
            "activate_sheet": "Bags",
            "filters": s05_filters,
            "viewport": {"width": 1280, "height": 960},
            "capture_mode": "download_image",
            "post_filter_wait_ms": INVENTORY_MONTHLY_DOWNLOAD_POST_FILTER_WAIT_MS,
            "ui_filter_mode": "script",
            "ui_filter_actions": bags_shoes_ui_filter_actions,
            "ui_filter_post_wait_ms": 8000,
            "ui_filter_forbidden_text_patterns": ["当前筛选器没有要可视化的数据"],
            "params": {},
            "raw_screenshot_name": "inventory-s05-bags-download.png",
            "note": "S05 bags dashboard export after applying active-sheet filters.",
        },
        {
            "capture_id": "shoes_full",
            "hash_url": "#/views/CKInventoryMonthlyReport/Shoes?:iid=1",
            "inner_frame_fragment": "/views/CKInventoryMonthlyReport/Shoes?",
            "activate_sheet": "Shoes",
            "filters": s06_filters,
            "capture_mode": "download_image",
            "viewport": {"width": 1280, "height": 960},
            "post_filter_wait_ms": INVENTORY_MONTHLY_DOWNLOAD_POST_FILTER_WAIT_MS,
            "ui_filter_mode": "script",
            "ui_filter_actions": bags_shoes_ui_filter_actions,
            "ui_filter_post_wait_ms": 8000,
            "ui_filter_forbidden_text_patterns": ["当前筛选器没有要可视化的数据"],
            "params": {},
            "raw_screenshot_name": "inventory-s06-shoes-download.png",
            "note": "S06 shoes dashboard export after applying active-sheet filters.",
        },
        {
            "capture_id": "discount_regular",
            "hash_url": "#/views/CKInventoryMonthlyReport/DiscountRegular?:iid=1",
            "inner_frame_fragment": "/views/CKInventoryMonthlyReport/DiscountRegular?",
            "activate_sheet": "Discount & Regular",
            "filters": s07_filters,
            "capture_mode": "download_image",
            "post_filter_wait_ms": INVENTORY_MONTHLY_DOWNLOAD_POST_FILTER_WAIT_MS,
            "normalize_download_size": {"width": 1400, "height": 3320},
            "raw_screenshot_name": "inventory-s07-discount.png",
            "note": "S07 discount dashboard export after applying active-sheet filters.",
        },
        {
            "capture_id": "subcategory",
            "hash_url": "#/views/CKInventoryMonthlyReport/Subcategory?:iid=1",
            "inner_frame_fragment": "/views/CKInventoryMonthlyReport/Subcategory?",
            "activate_sheet": "Subcategory",
            "filters": s08_filters,
            "capture_mode": "download_image",
            "viewport": {"width": 1400, "height": 1200},
            "post_filter_wait_ms": INVENTORY_MONTHLY_DOWNLOAD_POST_FILTER_WAIT_MS,
            "params": {},
            "normalize_download_size": {"width": 1400, "height": 3320},
            "raw_screenshot_name": "inventory-s08-subcategory.png",
            "note": "S08 subcategory dashboard export after applying active-sheet filters.",
        },
    ]

    captures = []
    for info in capture_template:
        spec = {
            "capture_id": info["capture_id"],
            "session": SESSION_NAME,
            "hash_url": info["hash_url"],
            "inner_frame_fragment": info["inner_frame_fragment"],
            "activate_sheet": info["activate_sheet"],
            "filters": info.get("filters", filters),
            "params": info.get(
                "params",
                {
                    "year2": compare_year,
                    "Year2": compare_year,
                },
            ),
            "raw_screenshot_name": info["raw_screenshot_name"],
            "note": info["note"],
        }
        for optional_key in [
            "capture_mode",
            "tableau_context",
            "filter_apply_mode",
            "download_strategy",
            "viewport",
            "post_filter_wait_ms",
            "tableau_wait_ms",
            "download_wait_ms",
            "min_download_file_size_bytes",
            "download_validation_retry_interval_ms",
            "download_validation_max_attempts",
            "normalize_download_size",
            "ui_filter_mode",
            "ui_filter_actions",
            "ui_filter_post_wait_ms",
            "ui_filter_forbidden_text_patterns",
        ]:
            if optional_key in info:
                spec[optional_key] = info[optional_key]
        captures.append(spec)

    assets = [
        {
            "slide_code": "S04",
            "slide": 4,
            "shape_name": "鍥剧墖 1",
            "shape_id": 2,
            "asset_name": "s04_category_overall_top",
            "capture_id": "overall",
            "crop": {"left": 0, "top": 70, "width": 1400, "height": 440},
            "resize_to": {"width": 948, "height": 208},
            "source_view": "Overall",
            "note": "S04 top row.",
        },
        {
            "slide_code": "S04",
            "slide": 4,
            "shape_name": "鍥剧墖 3",
            "shape_id": 4,
            "asset_name": "s04_category_overall_mid",
            "capture_id": "overall",
            "crop": {"left": 0, "top": 952, "width": 1400, "height": 420},
            "resize_to": {"width": 948, "height": 207},
            "source_view": "Overall",
            "note": "S04 middle row.",
        },
        {
            "slide_code": "S04",
            "slide": 4,
            "shape_name": "鍥剧墖 4",
            "shape_id": 5,
            "asset_name": "s04_category_overall_bottom",
            "capture_id": "overall",
            "crop": {"left": 0, "top": 2682, "width": 1400, "height": 640},
            "resize_to": {"width": 948, "height": 310},
            "source_view": "Overall",
            "note": "S04 bottom block.",
        },
        {
            "slide_code": "S05",
            "slide": 5,
            "shape_name": "Picture 1",
            "shape_id": 5,
            "asset_name": "s05_bags_top",
            "capture_id": "bags_full",
            "crop": {"left": 8, "top": 70, "width": 1148, "height": 250},
            "resize_to": {"width": 1093, "height": 237},
            "source_view": "Bags",
            "note": "S05 top row.",
        },
        {
            "slide_code": "S05",
            "slide": 5,
            "shape_name": "Picture 2",
            "shape_id": 7,
            "asset_name": "s05_bags_bottom",
            "capture_id": "bags_full",
            "crop": {"left": 8, "top": 1325, "width": 1148, "height": 395},
            "resize_to": {"width": 1093, "height": 376},
            "source_view": "Bags",
            "note": "S05 bottom block.",
        },
        {
            "slide_code": "S06",
            "slide": 6,
            "shape_name": "鍥剧墖 2",
            "shape_id": 3,
            "asset_name": "s06_shoes_top",
            "capture_id": "shoes_full",
            "crop": {"left": 8, "top": 70, "width": 1148, "height": 250},
            "resize_to": {"width": 1093, "height": 237},
            "source_view": "Shoes",
            "note": "S06 top row.",
        },
        {
            "slide_code": "S06",
            "slide": 6,
            "shape_name": "鍥剧墖 6",
            "shape_id": 7,
            "asset_name": "s06_shoes_bottom",
            "capture_id": "shoes_full",
            "crop": {"left": 8, "top": 1325, "width": 1148, "height": 395},
            "resize_to": {"width": 1093, "height": 376},
            "source_view": "Shoes",
            "note": "S06 bottom block.",
        },
        {
            "slide_code": "S07",
            "slide": 7,
            "shape_name": "鍥剧墖 2",
            "shape_id": 3,
            "asset_name": "s07_discount",
            "capture_id": "discount_regular",
            "crop": {"left": 0, "top": 140, "width": 1400, "height": 1400},
            "resize_to": {"width": 1186, "height": 649},
            "source_view": "Discount & Regular",
            "note": "S07 chart block.",
        },
        {
            "slide_code": "S08",
            "slide": 8,
            "shape_name": "鍥剧墖 1",
            "shape_id": 2,
            "asset_name": "s08_subcategory",
            "capture_id": "subcategory",
            "crop": {"left": 0, "top": 140, "width": 1400, "height": 1760},
            "resize_to": {"width": 944, "height": 731},
            "source_view": "Subcategory",
            "note": "S08 chart block.",
        },
    ]

    return {"captures": captures, "assets": assets}

def run_cmd(
    args: list[str],
    *,
    cwd: Path,
    timeout: int = 120,
    check: bool = True,
) -> subprocess.CompletedProcess[str]:
    """Helper."""
    env = os.environ.copy()
    local_tmp = cwd / ".tmp"
    npm_cache = cwd / ".npm-cache"
    browsers_path = cwd / ".ms-playwright"
    daemon_dir = cwd / ".playwright-daemon"
    for path in (local_tmp, npm_cache, browsers_path, daemon_dir):
        path.mkdir(parents=True, exist_ok=True)
    env["TEMP"] = str(local_tmp)
    env["TMP"] = str(local_tmp)
    env["npm_config_cache"] = str(npm_cache)
    env["NPM_CONFIG_CACHE"] = str(npm_cache)
    env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path)
    env["PLAYWRIGHT_DAEMON_SESSION_DIR"] = str(daemon_dir)
    creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
    process = subprocess.Popen(
        args,
        cwd=str(cwd),
        env=env,
        text=True,
        encoding="utf-8",
        errors="replace",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        creationflags=creationflags,
    )
    try:
        stdout, stderr = process.communicate(timeout=timeout)
    except subprocess.TimeoutExpired as exc:
        if process.poll() is None:
            if os.name == "nt":
                subprocess.run(
                    ["taskkill", "/F", "/T", "/PID", str(process.pid)],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    check=False,
                )
            else:
                process.kill()
        stdout, stderr = process.communicate()
        exc.stdout = stdout
        exc.stderr = stderr
        raise

    result = subprocess.CompletedProcess(args, process.returncode, stdout, stderr)
    if check and process.returncode != 0:
        raise subprocess.CalledProcessError(
            process.returncode,
            args,
            output=stdout,
            stderr=stderr,
        )
    return result


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 resolve_playwright_download_path(filename: str, download_dir: Path | None = None) -> Path:
    """Use a user-writable path for Playwright download.saveAs()."""
    base_dir = download_dir if download_dir is not None else Path.home() / "output" / "vip-report" / "playwright-downloads"
    path = base_dir / filename
    path.parent.mkdir(parents=True, exist_ok=True)
    return path


def resolve_session_capture_path(root: Path, session: str, filename: str) -> Path:
    """Helper."""
    return root / "output" / "playwright" / session / filename


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.waitForLoadState('domcontentloaded').catch(() => null);
  await page.waitForFunction(
    () => !location.href.includes('/#/signin') && document.readyState !== 'loading',
    null,
    {{ timeout: 10000 }}
  ).catch(() => null);
  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};
  if (spec.viewport && spec.viewport.width && spec.viewport.height) {{
    await page.setViewportSize({{
      width: Number(spec.viewport.width),
      height: Number(spec.viewport.height),
    }});
  }}
  await page.waitForLoadState('domcontentloaded').catch(() => null);

  async function locateTableauTarget(timeoutMs, preferredFragment) {{
    const deadline = Date.now() + timeoutMs;
    let lastFrames = [];
    while (Date.now() < deadline) {{
      const candidates = [];
      if (preferredFragment) {{
        for (const frame of page.frames()) {{
          if (frame !== page.mainFrame() && (frame.url() || '').includes(preferredFragment)) {{
            candidates.push(frame);
          }}
        }}
      }}
      candidates.push(page);
      lastFrames = candidates.map((candidate) => candidate === page ? page.url() : candidate.url());

      for (const candidate of candidates) {{
        try {{
          await candidate.waitForFunction(
            () => !!(window.tableau && window.tableau.VizManager && window.tableau.VizManager.getVizs().length),
            null,
            {{ timeout: 1000 }}
          );
          return candidate;
        }} catch (error) {{}}
      }}

      await page.waitForTimeout(1000);
    }}

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

  const tableauTarget = await locateTableauTarget(
    Number(spec.tableau_wait_ms || 60000),
    spec.inner_frame_fragment || ''
  );

  return await tableauTarget.evaluate(async (config) => {{
    // Tableau 鍋跺彂浼氬嚭鐜?Viz 宸插垱寤轰絾 workbook 灏氭湭灏辩华锛岄渶鏄惧紡绛夊緟銆?
    const deadline = Date.now() + 30000;
    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 new Promise((resolve) => setTimeout(resolve, 500));
    }}
    if (!workbook) {{
      throw new Error('Tableau workbook is not ready');
    }}
    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();
    }}
    // 鍏堝 activeSheet锛圖ashboard锛夊簲鐢ㄧ瓫閫夛紝鍐嶅洖閫€鍒板唴閮?worksheet銆?
    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 = [];
    const dashboardName = activeSheet && typeof activeSheet.getName === 'function'
      ? activeSheet.getName()
      : null;

    function normalizeText(value) {{
      if (value === null || value === undefined) {{
        return '';
      }}
      return String(value).trim();
    }}

    function normalizeWorksheetName(value) {{
      return normalizeText(value).toLowerCase();
    }}

    function isNonEmptyFilterValue(value) {{
      const text = normalizeText(value).toUpperCase();
      if (!text) {{
        return false;
      }}
      if (text === 'NULL' || text === '(NULL)' || text === 'ALL' || text === '(ALL)' || text === '全部') {{
        return false;
      }}
      return true;
    }}

    async function readAppliedFilterValues(worksheet, field) {{
      if (!worksheet || typeof worksheet.getFiltersAsync !== 'function') {{
        return [];
      }}
      try {{
        const filters = await worksheet.getFiltersAsync();
        const matches = (filters || []).filter((candidate) => {{
          const fieldName = normalizeText(candidate?.getFieldName?.());
          return fieldName === normalizeText(field);
        }});
        if (!matches.length) {{
          return [];
        }}
        const labels = [];
        for (const match of matches) {{
          if (!match || typeof match.getAppliedValues !== 'function') {{
            continue;
          }}
          const applied = match.getAppliedValues() || [];
          for (const entry of applied) {{
            if (!entry) {{
              continue;
            }}
            const formatted = normalizeText(entry.formattedValue);
            if (formatted) {{
              labels.push(formatted);
              continue;
            }}
            const raw = normalizeText(entry.value);
            if (raw) {{
              labels.push(raw);
            }}
          }}
        }}
        return [...new Set(labels)];
      }} catch (error) {{
        return [];
      }}
    }}

    async function applyFilterCandidate(field, values, requireNonEmpty, selectedTargets, skipValueValidation) {{
      let anySuccess = false;
      let anyWorksheetSuccess = false;
      for (const worksheet of selectedTargets) {{
        const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : 'unknown';
        try {{
          await worksheet.applyFilterAsync(field, values, updateType);
          const appliedValues = skipValueValidation ? [] : await readAppliedFilterValues(worksheet, field);
          const hasNonEmptyValue =
            skipValueValidation
              ? true
              : (appliedValues.length === 0 ? !requireNonEmpty : appliedValues.some((value) => isNonEmptyFilterValue(value)));
          if (!hasNonEmptyValue) {{
            filterApply.push({{
              field,
              values,
              worksheet: worksheetName,
              ok: false,
              error: 'filter applied but resolved to empty selection',
              applied_values: appliedValues,
            }});
            continue;
          }}
          anySuccess = true;
          if (!dashboardName || worksheetName !== dashboardName) {{
            anyWorksheetSuccess = true;
          }}
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: true,
            applied_values: appliedValues,
          }});
        }} catch (error) {{
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: false,
            error: String(error && error.message || error),
          }});
        }}
      }}
      return {{ anySuccess, anyWorksheetSuccess }};
    }}

    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 requireWorksheetMatch =
        (filter.require_worksheet_match !== false) && worksheets.length > 0;
      const applyAllFields = !!filter.apply_all_fields;
      const requireNonEmpty = !!filter.require_non_empty;
      const skipValueValidation = !!filter.skip_value_validation;
      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));
        }});
      }}
      let matched = false;

      for (const field of fieldCandidates) {{
        let fieldMatched = false;
        for (const values of valueCandidates) {{
          const result = await applyFilterCandidate(
            field,
            values,
            requireNonEmpty,
            selectedTargets,
            skipValueValidation,
          );
          if (result.anySuccess && (!requireWorksheetMatch || result.anyWorksheetSuccess)) {{
            filterApply.push({{
              label: filter.label || field,
              field,
              values,
              worksheet: "summary",
              ok: true,
              matched_on_worksheet: result.anyWorksheetSuccess,
            }});
            matched = true;
            fieldMatched = true;
            break;
          }}
        }}
        if (fieldMatched && !applyAllFields) {{
          break;
        }}
        if (fieldMatched) {{
          continue;
        }}
      }}

      if (!matched) {{
        filterApply.push({{
          label: filter.label || null,
          field: null,
          worksheet: null,
          ok: false,
          error: "no candidate matched",
        }});
        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) {{}}
    }}

    function isVisible(node) {{
      if (!node || typeof node.getBoundingClientRect !== 'function') {{
        return false;
      }}
      const rect = node.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) {{
        return false;
      }}
      const style = window.getComputedStyle(node);
      return !!style && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
    }}

    function readRenderSnapshot() {{
      const loadingSelectors = [
        '[role="progressbar"]',
        '[aria-busy="true"]',
        '[class*="spinner"]',
        '[class*="Spinner"]',
        '[class*="loading"]',
        '[class*="Loading"]',
      ];
      const loadingCount = loadingSelectors.reduce((count, selector) => {{
        return count + Array.from(document.querySelectorAll(selector)).filter((node) => isVisible(node)).length;
      }}, 0);
      const text = ((document.body && document.body.innerText) || '')
        .replace(/\\s+/g, ' ')
        .trim();
      let textHash = 0;
      for (let index = 0; index < text.length; index += 1) {{
        textHash = ((textHash << 5) - textHash + text.charCodeAt(index)) | 0;
      }}
      const signaturePayload = {{
        href: location.href,
        title: document.title,
        textHash,
        textLength: text.length,
        textHead: text.slice(0, 500),
        textTail: text.slice(-500),
        svgCount: document.querySelectorAll('svg').length,
        canvasCount: document.querySelectorAll('canvas').length,
        tableCount: document.querySelectorAll('table').length,
        textNodeCount: document.querySelectorAll('text').length,
        imageCount: document.querySelectorAll('img').length,
      }};
      return {{
        loadingCount,
        signature: JSON.stringify(signaturePayload),
        signaturePayload,
      }};
    }}

    async function waitForRenderToSettle(maxWaitMs) {{
      const safeMaxWaitMs = Number(maxWaitMs || 0);
      if (safeMaxWaitMs <= 0) {{
        return {{ settled: true, reason: 'disabled' }};
      }}
      const pollIntervalMs = {INVENTORY_MONTHLY_RENDER_SETTLE_POLL_INTERVAL_MS};
      const requiredStablePolls = {INVENTORY_MONTHLY_RENDER_SETTLE_STABLE_POLLS};
      const deadline = Date.now() + safeMaxWaitMs;
      let previousSignature = null;
      let stablePollCount = 0;
      let lastSnapshot = null;
      let pollCount = 0;
      while (Date.now() < deadline) {{
        pollCount += 1;
        lastSnapshot = readRenderSnapshot();
        if (lastSnapshot.loadingCount === 0 && lastSnapshot.signature === previousSignature) {{
          stablePollCount += 1;
        }} else {{
          stablePollCount = 0;
        }}
        if (lastSnapshot.loadingCount === 0 && stablePollCount >= requiredStablePolls) {{
          return {{
            settled: true,
            waitedMs: safeMaxWaitMs - Math.max(0, deadline - Date.now()),
            pollCount,
            stablePollCount,
            lastSnapshot: lastSnapshot.signaturePayload,
          }};
        }}
        previousSignature = lastSnapshot.signature;
        const remainingMs = deadline - Date.now();
        if (remainingMs <= 0) {{
          break;
        }}
        await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs)));
      }}
      return {{
        settled: false,
        waitedMs: safeMaxWaitMs,
        pollCount,
        stablePollCount,
        loadingCount: lastSnapshot ? lastSnapshot.loadingCount : null,
        lastSnapshot: lastSnapshot ? lastSnapshot.signaturePayload : null,
      }};
    }}

    const settleResult = await waitForRenderToSettle(Number(config.post_filter_wait_ms || 7000));
    if (!settleResult.settled) {{
      throw new Error('Timed out waiting for Tableau data to settle after filtering: ' + JSON.stringify(settleResult));
    }}
    return {{
      activeSheet: activeSheet.getName(),
      targetCount: targets.length,
      filterApply,
      settleResult,
      filters: config.filters,
      params: config.params,
      url: location.href,
    }};
  }}, spec);
}}
"""


def build_capture_js(
    inner_frame_fragment: str,
    screenshot_name: str,
    *,
    ready_patterns: list[str] | None = None,
    viewport: dict[str, int] | None = None,
    capture_mode: str = "frame_body",
    scroll_selector: str | None = None,
    scroll_top: int | None = None,
    capture_wait_ms: int = 3000,
) -> str:
    """Helper."""
    payload = json.dumps(
        {
            "inner_frame_fragment": inner_frame_fragment,
            "screenshot_name": screenshot_name,
            "ready_patterns": ready_patterns or [],
            "viewport": viewport or {},
            "capture_mode": capture_mode,
            "scroll_selector": scroll_selector,
            "scroll_top": scroll_top,
            "capture_wait_ms": capture_wait_ms,
        },
        ensure_ascii=False,
    )
    return f"""async function(page) {{
  const spec = {payload};
  if (spec.viewport && spec.viewport.width && spec.viewport.height) {{
    await page.setViewportSize({{
      width: Number(spec.viewport.width),
      height: Number(spec.viewport.height),
    }});
    await page.waitForTimeout(1000);
  }}
  await page.waitForTimeout(3000);
  const frame = page.frames().find(
    (candidate) => candidate !== page.mainFrame() && candidate.url().includes(spec.inner_frame_fragment)
  );
  const target = frame || page;
  if (spec.ready_patterns && spec.ready_patterns.length > 0) {{
    await target.waitForFunction(
      (patterns) => {{
        const text = ((document.body && document.body.innerText) || '').toUpperCase();
        return patterns.every((pattern) => text.includes(String(pattern).toUpperCase()));
      }},
      spec.ready_patterns,
      {{ timeout: 60000 }}
    );
  }}
  if (frame && spec.scroll_selector) {{
    await frame.evaluate((payload) => {{
      const node = document.querySelector(payload.selector);
      if (!node) {{
        throw new Error(`Scroll target not found: ${{payload.selector}}`);
      }}
      node.scrollTop = Number(payload.scrollTop || 0);
    }}, {{ selector: spec.scroll_selector, scrollTop: spec.scroll_top || 0 }});
  }}
  await page.waitForTimeout(Number(spec.capture_wait_ms || 3000));
  if (spec.capture_mode === 'page') {{
    await page.screenshot({{
      path: spec.screenshot_name,
      scale: 'css',
    }});
  }} else {{
    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 build_download_image_js(
    activate_sheet: str,
    filters: list[dict[str, Any]],
    params: dict[str, Any],
    inner_frame_fragment: str,
    output_path: str,
    *,
    viewport: dict[str, int] | None = None,
    post_filter_wait_ms: int = 1500,
    tableau_wait_ms: int = 60000,
    download_wait_ms: int = 30000,
    tableau_context: str = "frame",
    filter_apply_mode: str = "per_filter",
    download_strategy: str = "generic",
) -> str:
    """Filter and export the Tableau view in one Playwright script."""
    payload = json.dumps(
        {
            "activate_sheet": activate_sheet,
            "filters": filters,
            "params": params,
            "inner_frame_fragment": inner_frame_fragment,
            "output_path": output_path,
            "viewport": viewport or {},
            "post_filter_wait_ms": post_filter_wait_ms,
            "tableau_wait_ms": tableau_wait_ms,
            "download_wait_ms": download_wait_ms,
            "tableau_context": tableau_context,
            "filter_apply_mode": filter_apply_mode,
            "download_strategy": download_strategy,
            "dialog_selectors": [
                '[data-tb-test-id="FileDownload-Dlg-Dialog-Glass-Root"]',
                '[data-tb-test-id="FileDownload-Dlg-Dialog-Floater-Root"]',
                '[data-tb-test-id="detailedErrorDialog-Dialog-Glass-Root"]',
                '[data-tb-test-id="detailedErrorDialog-Dialog-Glass"]',
            ],
        },
        ensure_ascii=False,
    )
    return f"""async function(page) {{
  const spec = {payload};
  if (spec.viewport && spec.viewport.width && spec.viewport.height) {{
    await page.setViewportSize({{
      width: Number(spec.viewport.width),
      height: Number(spec.viewport.height),
    }});
    await page.waitForTimeout(1000);
  }}
  await page.waitForLoadState('domcontentloaded').catch(() => null);
  await page.waitForTimeout(1500);

  async function inspectFrame(frame, preferredFragment) {{
    const frameUrl = typeof frame.url === 'function' ? frame.url() : '';
    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,
        frameUrl,
        matchesTargetFrame: !!(preferredFragment && frameUrl && frameUrl.includes(preferredFragment)),
      }};
    }} catch (error) {{
      return {{
        url: frameUrl,
        title: null,
        readyState: null,
        hasTableau: false,
        vizCount: 0,
        frameUrl,
        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(1000);
    }}

    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 || 60000), spec.inner_frame_fragment || '');
  const targetFrame = located.frame;
  const evaluationTarget = spec.tableau_context === 'page' ? page : targetFrame;

  const exportResult = await evaluationTarget.evaluate(async (config) => {{
    const viz = window.tableau?.VizManager?.getVizs?.()?.[0];
    if (!viz || typeof viz.getWorkbook !== 'function') {{
      throw new Error('Tableau viz is not ready for export.');
    }}
    const workbook = viz.getWorkbook();
    const updateType = window.tableau.FilterUpdateType.REPLACE;

    function normalizeText(value) {{
      if (value === null || value === undefined) {{
        return '';
      }}
      return String(value).trim();
    }}

    function normalizeWorksheetName(value) {{
      return normalizeText(value).toLowerCase();
    }}

    function isNonEmptyFilterValue(value) {{
      const text = normalizeText(value).toUpperCase();
      if (!text) {{
        return false;
      }}
      if (text === 'NULL' || text === '(NULL)' || text === 'ALL' || text === '(ALL)' || text === '全部') {{
        return false;
      }}
      return true;
    }}

    if ((config.download_strategy || '') === 'dashboard_filters') {{
      try {{
        await workbook.revertAllAsync();
      }} catch (error) {{}}
      await workbook.activateSheetAsync(config.activate_sheet);
      const activeSheet = workbook.getActiveSheet();
      const filterApply = [];

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

      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 field = fieldCandidates[0] || null;
        const values = valueCandidates[0] || null;
        if (!field || !Array.isArray(values) || values.length === 0) {{
          if (filter.required !== false) {{
            throw new Error(`Required filter '${{filter.label || fieldCandidates.join('/')}}' is missing an exact field/value pair.`);
          }}
          continue;
        }}
        try {{
          await activeSheet.applyFilterAsync(field, values, updateType);
          filterApply.push({{
            field,
            values,
            worksheet: typeof activeSheet.getName === 'function' ? activeSheet.getName() : 'unknown',
            ok: true,
          }});
        }} catch (error) {{
          filterApply.push({{
            field,
            values,
            worksheet: typeof activeSheet.getName === 'function' ? activeSheet.getName() : 'unknown',
            ok: false,
            error: String(error && error.message || error),
          }});
          if (filter.required !== false) {{
            throw new Error(`Required filter '${{filter.label || field}}' did not match any worksheet.`);
          }}
        }}
      }}

      if (Number(config.post_filter_wait_ms || 0) > 0) {{
        await new Promise((resolve) => setTimeout(resolve, Number(config.post_filter_wait_ms)));
      }}

      return {{
        frameUrl: location.href,
        activeSheet: activeSheet && typeof activeSheet.getName === 'function'
          ? activeSheet.getName()
          : null,
        filterApply,
      }};
    }}

    async function readAppliedFilterValues(worksheet, field) {{
      if (!worksheet || typeof worksheet.getFiltersAsync !== 'function') {{
        return [];
      }}
      try {{
        const filters = await worksheet.getFiltersAsync();
        const matches = (filters || []).filter((candidate) => {{
          const fieldName = normalizeText(candidate?.getFieldName?.());
          return fieldName === normalizeText(field);
        }});
        if (!matches.length) {{
          return [];
        }}
        const labels = [];
        for (const match of matches) {{
          if (!match || typeof match.getAppliedValues !== 'function') {{
            continue;
          }}
          const applied = match.getAppliedValues() || [];
          for (const entry of applied) {{
            if (!entry) {{
              continue;
            }}
            const formatted = normalizeText(entry.formattedValue);
            if (formatted) {{
              labels.push(formatted);
              continue;
            }}
            const raw = normalizeText(entry.value);
            if (raw) {{
              labels.push(raw);
            }}
          }}
        }}
        return [...new Set(labels)];
      }} catch (error) {{
        return [];
      }}
    }}

    try {{
      await workbook.revertAllAsync();
    }} catch (error) {{}}
    await workbook.activateSheetAsync(config.activate_sheet);
    const activeSheet = workbook.getActiveSheet();
    const worksheets =
      activeSheet && typeof activeSheet.getWorksheets === 'function'
        ? activeSheet.getWorksheets()
        : [];
    const targets = [];
    if (activeSheet) {{
      targets.push(activeSheet);
    }}
    for (const worksheet of worksheets) {{
      if (!targets.includes(worksheet)) {{
        targets.push(worksheet);
      }}
    }}
    const dashboardName = activeSheet && typeof activeSheet.getName === 'function'
      ? activeSheet.getName()
      : null;
    const filterApply = [];

    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, requireNonEmpty, selectedTargets, skipValueValidation) {{
      let anySuccess = false;
      let anyWorksheetSuccess = false;
      for (const worksheet of selectedTargets) {{
        const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : 'unknown';
        try {{
          await worksheet.applyFilterAsync(field, values, updateType);
          const appliedValues = skipValueValidation ? [] : await readAppliedFilterValues(worksheet, field);
          const hasNonEmptyValue =
            skipValueValidation
              ? true
              : (appliedValues.length === 0 ? !requireNonEmpty : appliedValues.some((value) => isNonEmptyFilterValue(value)));
          if (!hasNonEmptyValue) {{
            filterApply.push({{
              field,
              values,
              worksheet: worksheetName,
              ok: false,
              error: 'filter applied but resolved to empty selection',
              applied_values: appliedValues,
            }});
            continue;
          }}
          anySuccess = true;
          if (!dashboardName || worksheetName !== dashboardName) {{
            anyWorksheetSuccess = true;
          }}
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: true,
            applied_values: appliedValues,
          }});
        }} catch (error) {{
          filterApply.push({{
            field,
            values,
            worksheet: worksheetName,
            ok: false,
            error: String(error && error.message || error),
          }});
        }}
      }}
      return {{ anySuccess, anyWorksheetSuccess }};
    }}

    async function applyConfiguredFilter(filter, selectedTargets) {{
      const fieldCandidates = filter.field_candidates || (filter.field ? [filter.field] : []);
      const valueCandidates = filter.value_candidates || (filter.values ? [filter.values] : []);
      const requireWorksheetMatch =
        (filter.require_worksheet_match !== false) && worksheets.length > 0;
      const applyAllFields = !!filter.apply_all_fields;
      const requireNonEmpty = !!filter.require_non_empty;
      const skipValueValidation = !!filter.skip_value_validation;
      let matched = false;

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

      if (!matched && filter.required !== false) {{
        throw new Error(`Required filter '${{filter.label || fieldCandidates.join('/')}}' did not match any worksheet.`);
      }}
      return matched;
    }}

    if ((config.filter_apply_mode || 'per_filter') === 'per_worksheet') {{
      const worksheetQueue = [];
      const seenWorksheetNames = new Set();
      for (const filter of config.filters || []) {{
        for (const worksheet of resolveSelectedTargets(filter)) {{
          const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : '';
          const normalizedWorksheetName = normalizeWorksheetName(worksheetName);
          if (!normalizedWorksheetName || seenWorksheetNames.has(normalizedWorksheetName)) {{
            continue;
          }}
          if (dashboardName && normalizedWorksheetName === normalizeWorksheetName(dashboardName)) {{
            continue;
          }}
          seenWorksheetNames.add(normalizedWorksheetName);
          worksheetQueue.push(worksheet);
        }}
      }}

      for (const worksheet of worksheetQueue) {{
        for (const filter of config.filters || []) {{
          const selectedTargets = resolveSelectedTargets(filter);
          if (!selectedTargets.some((candidate) => candidate === worksheet)) {{
            continue;
          }}
          const fieldCandidates = filter.field_candidates || (filter.field ? [filter.field] : []);
          const valueCandidates = filter.value_candidates || (filter.values ? [filter.values] : []);
          const field = fieldCandidates[0] || null;
          const values = valueCandidates[0] || null;
          const requireNonEmpty = !!filter.require_non_empty;
          const skipValueValidation = !!filter.skip_value_validation;
          const worksheetName = typeof worksheet.getName === 'function' ? worksheet.getName() : 'unknown';
          if (!field || !Array.isArray(values) || values.length === 0) {{
            if (filter.required !== false) {{
              throw new Error(`Required filter '${{filter.label || fieldCandidates.join('/')}}' is missing an exact field/value pair.`);
            }}
            continue;
          }}
          try {{
            await worksheet.applyFilterAsync(field, values, updateType);
            const appliedValues = skipValueValidation ? [] : await readAppliedFilterValues(worksheet, field);
            const hasNonEmptyValue =
              skipValueValidation
                ? true
                : (appliedValues.length === 0 ? !requireNonEmpty : appliedValues.some((value) => isNonEmptyFilterValue(value)));
            if (!hasNonEmptyValue) {{
              filterApply.push({{
                field,
                values,
                worksheet: worksheetName,
                ok: false,
                error: 'filter applied but resolved to empty selection',
                applied_values: appliedValues,
              }});
              if (filter.required !== false) {{
                throw new Error(`Required filter '${{filter.label || field}}' did not match any worksheet.`);
              }}
              continue;
            }}
            filterApply.push({{
              field,
              values,
              worksheet: worksheetName,
              ok: true,
              applied_values: appliedValues,
            }});
          }} catch (error) {{
            filterApply.push({{
              field,
              values,
              worksheet: worksheetName,
              ok: false,
              error: String(error && error.message || error),
            }});
            if (filter.required !== false) {{
              throw new Error(`Required filter '${{filter.label || field}}' did not match any worksheet.`);
            }}
          }}
        }}
      }}
    }} else {{
      for (const filter of config.filters || []) {{
        await applyConfiguredFilter(filter, resolveSelectedTargets(filter));
      }}
    }}

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

    if (Number(config.post_filter_wait_ms || 0) > 0) {{
      await new Promise((resolve) => setTimeout(resolve, Number(config.post_filter_wait_ms)));
    }}

    return {{
      frameUrl: location.href,
      activeSheet: activeSheet && typeof activeSheet.getName === 'function'
        ? activeSheet.getName()
        : null,
      filterApply,
    }};
  }}, spec);

  await page.waitForTimeout(1000);
  const downloadButtonSelectors = [
    'button[title*="下载"], button[aria-label*="下载"], button[title*="Download"], button[aria-label*="Download"]',
    'button',
  ];
  const downloadButtonScopes = [
    targetFrame,
    ...page.frames().filter((frame) => frame !== targetFrame),
  ];
  let toolbarDownloadButton = null;
  let downloadButtonScope = null;
  for (const scope of downloadButtonScopes) {{
    for (const selector of downloadButtonSelectors) {{
      const candidate =
        selector === 'button'
          ? scope.locator(selector).filter({{ hasText: '下载' }}).first()
          : scope.locator(selector).first();
      try {{
        await candidate.waitFor({{ state: 'visible', timeout: 5000 }});
        toolbarDownloadButton = candidate;
        downloadButtonScope = scope;
        break;
      }} catch (error) {{}}
    }}
    if (toolbarDownloadButton) {{
      break;
    }}
  }}
  if (!toolbarDownloadButton) {{
    throw new Error('Download toolbar button was not found in either the Tableau frame or page.');
  }}
  await toolbarDownloadButton.click();
  await page.waitForTimeout(500);
  const [download] = await Promise.all([
    page.waitForEvent('download', {{ timeout: Number(spec.download_wait_ms || 30000) }}),
    (downloadButtonScope || targetFrame).evaluate(() => {{
      const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
      const item = items.find((node) => {{
        const text = (node.textContent || '').trim();
        return text === '图像' || text === '图片' || text === 'Image';
      }});
      if (!item) {{
        throw new Error('Image download option was not found after opening the Tableau download menu.');
      }}
      item.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }}));
      item.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }}));
      item.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    }}),
  ]);
  await download.saveAs(spec.output_path);
  return {{
    ...exportResult,
    pageUrl: page.url(),
    outputPath: spec.output_path,
    suggestedFilename: download.suggestedFilename(),
    frameSnapshots: located.snapshots,
  }};
}}
"""


def build_apply_quick_filters_js(spec: dict[str, Any]) -> str:
    """Apply Tableau quick filters through the visible UI."""
    payload = json.dumps(spec, ensure_ascii=False)
    return f"""async function(page) {{
  const spec = {payload};
  const allFilterValue = String(spec.all_filter_value || '(全部)');
  await page.waitForLoadState('domcontentloaded').catch(() => null);

  async function readLocatorText(locator) {{
    const count = await locator.count().catch(() => 0);
    if (!count) {{
      return '';
    }}
    const first = locator.first();
    return String(
      await first.innerText().catch(async () => await first.textContent().catch(() => '')) || ''
    ).trim();
  }}

  async function readFrameText(frame) {{
    return await frame.evaluate(() => {{
      return String((document.body && document.body.innerText) || '')
        .replace(/\\s+/g, ' ')
        .trim();
    }}).catch(() => '');
  }}

  function escapeRegExp(value) {{
    return String(value || '').replace(/[.*+?^${{}}()|[\\]\\\\]/g, '\\\\$&');
  }}

  function buildExactMatcher(value) {{
    return new RegExp(`^\\\\s*${{escapeRegExp(value)}}\\\\s*$`, 'i');
  }}

  async function collectQuickFilterHeadings(frame) {{
    const headings = [];
    const locator = frame.getByRole('heading');
    const count = await locator.count().catch(() => 0);
    for (let index = 0; index < count; index += 1) {{
      const heading = await readLocatorText(locator.nth(index));
      if (heading) {{
        headings.push(heading);
      }}
    }}
    return headings;
  }}

  async function locateQuickFilterFrame(timeoutMs, preferredFragment) {{
    const deadline = Date.now() + timeoutMs;
    const requiredLabels = (spec.filter_actions || [])
      .map((action) => String(action?.label || '').trim().toLowerCase())
      .filter(Boolean);
    let lastFrames = [];
    while (Date.now() < deadline) {{
      lastFrames = [];
      for (const frame of page.frames()) {{
        const frameUrl = typeof frame.url === 'function' ? frame.url() : '';
        if (preferredFragment && !frameUrl.includes(preferredFragment)) {{
          continue;
        }}
        const buttonCount = await frame.getByRole('button').count().catch(() => 0);
        const headings = await collectQuickFilterHeadings(frame).catch(() => []);
        const normalizedHeadings = headings.map((heading) => String(heading || '').trim().toLowerCase());
        const matchedLabelCount = requiredLabels.filter((label) =>
          normalizedHeadings.some((heading) => heading.includes(label))
        ).length;
        const snapshot = {{
          frameUrl,
          buttonCount,
          headings,
          matchedLabelCount,
        }};
        lastFrames.push(snapshot);
        if (buttonCount >= 15 && (requiredLabels.length === 0 || matchedLabelCount > 0)) {{
          return {{ frame, lastFrames }};
        }}
      }}
      await page.waitForTimeout(1000);
    }}
    throw new Error('Timed out waiting for Tableau quick filters: ' + JSON.stringify(lastFrames));
  }}

  async function locateQuickFilterButton(frame, label, timeoutMs) {{
    const deadline = Date.now() + timeoutMs;
    const matcher = new RegExp(escapeRegExp(label), 'i');
    while (Date.now() < deadline) {{
      const heading = frame.getByRole('heading', {{ name: matcher }}).first();
      const headingCount = await heading.count().catch(() => 0);
      if (headingCount > 0 && await heading.isVisible().catch(() => true)) {{
        const followingButton = heading.locator('xpath=following::button[1]').first();
        const followingCount = await followingButton.count().catch(() => 0);
        if (followingCount > 0 && await followingButton.isVisible().catch(() => true)) {{
          return followingButton;
        }}
      }}
      const button = frame.getByRole('button', {{ name: matcher }}).first();
      const buttonCount = await button.count().catch(() => 0);
      if (buttonCount > 0 && await button.isVisible().catch(() => true)) {{
        return button;
      }}
      await page.waitForTimeout(1000);
    }}
    return null;
  }}

  async function waitForButtonText(button, expectedValue, timeoutMs) {{
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {{
      const buttonText = await readLocatorText(button);
      if (!expectedValue || buttonText.includes(expectedValue)) {{
        return buttonText;
      }}
      await page.waitForTimeout(1000);
    }}
    return await readLocatorText(button);
  }}

  async function locateFilterListbox(frame, desiredValues) {{
    const listboxes = frame.getByRole('listbox');
    const count = await listboxes.count().catch(() => 0);
    let fallback = null;
    for (let index = count - 1; index >= 0; index -= 1) {{
      const candidate = listboxes.nth(index);
      if (!await candidate.isVisible().catch(() => false)) {{
        continue;
      }}
      const checkboxCount = await candidate.getByRole('checkbox').count().catch(() => 0);
      if (checkboxCount <= 0) {{
        continue;
      }}
      if (!fallback) {{
        fallback = candidate;
      }}
      for (const desiredValue of desiredValues) {{
        const row = candidate.getByRole('checkbox', {{ name: buildExactMatcher(desiredValue) }}).first();
        if (await row.count().catch(() => 0)) {{
          return candidate;
        }}
      }}
      const allRow = candidate.getByRole('checkbox', {{ name: buildExactMatcher(allFilterValue) }}).first();
      if (await allRow.count().catch(() => 0)) {{
        return candidate;
      }}
    }}
    return fallback;
  }}

  async function openQuickFilter(frame, button, label, desiredValues, timeoutMs) {{
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {{
      await button.click({{ timeout: 5000 }}).catch(async () => {{
        await button.click({{ timeout: 5000, force: true }}).catch(() => null);
      }});
      await button.evaluate((element) => {{
        for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {{
          element.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, composed: true }}));
        }}
      }}).catch(() => null);
      const listbox = await locateFilterListbox(frame, desiredValues);
      const listboxCount = listbox ? await listbox.count().catch(() => 0) : 0;
      if (listbox && listboxCount > 0 && await listbox.isVisible().catch(() => false)) {{
        await page.waitForTimeout(500);
        return listbox;
      }}
      await page.waitForTimeout(500);
    }}
    throw new Error(`Failed to open quick filter ${{label}}`);
  }}

  async function closeQuickFilter(button) {{
    try {{
      await button.click({{ timeout: 5000 }});
      await page.waitForTimeout(1000);
      return 'button-click';
    }} catch (error) {{}}
    try {{
      await button.click({{ timeout: 5000, force: true }});
      await page.waitForTimeout(1000);
      return 'button-click-force';
    }} catch (error) {{}}
    await page.keyboard.press('Escape').catch(() => null);
    await page.waitForTimeout(1000);
    return 'escape';
  }}

  async function findRow(listbox, value) {{
    const rows = listbox.getByRole('checkbox', {{ name: buildExactMatcher(value) }});
    const count = await rows.count().catch(() => 0);
    let fallback = null;
    for (let index = 0; index < count; index += 1) {{
      const row = rows.nth(index);
      if (!fallback) {{
        fallback = row;
      }}
      if (await row.isVisible().catch(() => false)) {{
        return row;
      }}
    }}
    return fallback;
  }}

  async function readRowState(row) {{
    const count = await row.count().catch(() => 0);
    if (!count) {{
      return {{
        exists: false,
        checked: false,
        text: '',
      }};
    }}
    return await row.evaluate((element) => {{
      const target = element.closest('[role="checkbox"]') || element;
      const input = target.querySelector('input.FICheckRadio');
      const className = String(target.className || '');
      const ariaChecked = String(target.getAttribute('aria-checked') || '');
      const inputChecked = !!(input && input.checked);
      return {{
        exists: true,
        text: String(target.textContent || '').trim(),
        ariaChecked,
        className,
        inputChecked,
        checked: inputChecked || ariaChecked.toLowerCase() === 'true' || className.includes('FIChecked'),
      }};
    }}).catch((error) => ({{
      exists: true,
      checked: false,
      text: '',
      error: String(error && error.message || error),
    }}));
  }}

  async function clickFakeCheckbox(row) {{
    const fake = row.locator('.fakeCheckBox').first();
    const fakeCount = await fake.count().catch(() => 0);
    if (fakeCount > 0) {{
      try {{
        await fake.click({{ timeout: 5000 }});
        return 'fake-click';
      }} catch (error) {{}}
      try {{
        await fake.click({{ timeout: 5000, force: true }});
        return 'fake-click-force';
      }} catch (error) {{}}
    }}
    try {{
      await row.evaluate((element) => {{
        const target = element.closest('[role="checkbox"]') || element;
        const input = target.querySelector('input.FICheckRadio');
        if (input) {{
          input.click();
          return;
        }}
        const fallback = target.querySelector('.fakeCheckBox') || target;
        for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {{
          fallback.dispatchEvent(new MouseEvent(type, {{ bubbles: true, cancelable: true, composed: true }}));
        }}
      }});
      return 'dom-input-click';
    }} catch (error) {{}}
    await row.click({{ timeout: 5000, force: true }});
    return 'row-click-force';
  }}

  async function listCheckedRows(listbox) {{
    const rows = listbox.getByRole('checkbox');
    const count = await rows.count().catch(() => 0);
    const checkedRows = [];
    for (let index = 0; index < count; index += 1) {{
      const row = rows.nth(index);
      if (!await row.isVisible().catch(() => false)) {{
        continue;
      }}
      const state = await readRowState(row);
      if (state.checked) {{
        checkedRows.push({{ index, ...state }});
      }}
    }}
    return checkedRows;
  }}

  async function applyQuickFilter(frame, action) {{
    const label = String(action?.label || '').trim();
    const desiredValues = Array.isArray(action?.values)
      ? action.values.map((value) => String(value || '').trim()).filter(Boolean)
      : [];
    const forceVerify = !!action?.force_verify;
    const expectedButtonText =
      Object.prototype.hasOwnProperty.call(action || {{}}, 'expected_button_text')
        ? String(action?.expected_button_text || '').trim()
        : String(
            desiredValues.length === 1 ? desiredValues[0] : desiredValues.join(', ')
          ).trim();
    const button = await locateQuickFilterButton(frame, label, Number(spec.quick_filter_wait_ms || 30000));
    if (!button) {{
      return {{
        ok: false,
        action: 'missing-filter',
        detail: {{
          label,
          availableHeadings: await collectQuickFilterHeadings(frame).catch(() => []),
        }},
      }};
    }}

    const initialButtonText = await readLocatorText(button);
    if (!forceVerify && expectedButtonText && initialButtonText.includes(expectedButtonText)) {{
      return {{
        ok: true,
        action: 'already-set',
        detail: {{
          initialButtonText,
          finalButtonText: initialButtonText,
          expectedButtonText,
          desiredValues,
          checkedBefore: [],
          checkedAfter: [],
          desiredStates: [],
          actionsTaken: [],
          missingValues: [],
        }},
      }};
    }}

    const listbox = await openQuickFilter(
      frame,
      button,
      label,
      desiredValues,
      Number(spec.quick_filter_wait_ms || 30000),
    );
    const checkedBefore = await listCheckedRows(listbox);
    const actionsTaken = [];
    const deselectLabels = [];
    const allRow = await findRow(listbox, allFilterValue);
    if (allRow) {{
      const allRowState = await readRowState(allRow);
      if (allRowState.checked && !desiredValues.includes(allFilterValue)) {{
        const method = await clickFakeCheckbox(allRow);
        actionsTaken.push({{
          kind: 'deselect-all',
          label: allFilterValue,
          method,
        }});
        await page.waitForTimeout(300);
      }}
    }}

    const currentValue = String(initialButtonText || '').trim();
    if (
      currentValue &&
      currentValue !== expectedButtonText &&
      currentValue !== allFilterValue &&
      currentValue !== '(多个值)' &&
      currentValue !== '(无)'
    ) {{
      deselectLabels.push(currentValue);
    }} else {{
      const handledCheckedLabels = new Set();
      for (const checkedEntry of checkedBefore) {{
        const checkedLabel = String(checkedEntry.text || '').trim();
        if (
          !checkedLabel ||
          checkedLabel === allFilterValue ||
          desiredValues.includes(checkedLabel) ||
          handledCheckedLabels.has(checkedLabel)
        ) {{
          continue;
        }}
        handledCheckedLabels.add(checkedLabel);
        deselectLabels.push(checkedLabel);
      }}
    }}

    for (const deselectLabel of deselectLabels) {{
      const checkedRow = await findRow(listbox, deselectLabel);
      if (!checkedRow) {{
        continue;
      }}
      const method = await clickFakeCheckbox(checkedRow);
      actionsTaken.push({{
        kind: 'deselect',
        label: deselectLabel,
        method,
      }});
      await page.waitForTimeout(300);
    }}

    const missingValues = [];
    for (const desiredValue of desiredValues) {{
      const desiredRow = await findRow(listbox, desiredValue);
      if (!desiredRow) {{
        missingValues.push(desiredValue);
        continue;
      }}
      const desiredState = await readRowState(desiredRow);
      if (!desiredState.checked) {{
        const method = await clickFakeCheckbox(desiredRow);
        actionsTaken.push({{
          kind: 'select',
          label: desiredValue,
          method,
        }});
        await page.waitForTimeout(300);
      }}
    }}

    const desiredStates = [];
    for (const desiredValue of desiredValues) {{
      const desiredRow = await findRow(listbox, desiredValue);
      desiredStates.push({{
        label: desiredValue,
        ...(desiredRow ? await readRowState(desiredRow) : {{ exists: false, checked: false, text: desiredValue }}),
      }});
    }}
    const checkedAfter = await listCheckedRows(listbox);
    const checkedAfterLabels = [...new Set(
      checkedAfter
        .map((entry) => String(entry?.text || '').trim())
        .filter((value) => value && value !== allFilterValue)
    )];
    const desiredCheckedLabels = [...new Set(
      desiredValues
        .map((value) => String(value || '').trim())
        .filter((value) => value && value !== allFilterValue)
    )];
    const exactSelection =
      checkedAfterLabels.length === desiredCheckedLabels.length &&
      desiredCheckedLabels.every((value) => checkedAfterLabels.includes(value));
    const closeMethod = await closeQuickFilter(button);
    const finalButtonText = await waitForButtonText(button, expectedButtonText, Number(spec.close_wait_ms || 15000));
    const ok =
      missingValues.length === 0 &&
      desiredStates.every((entry) => entry.checked) &&
      exactSelection &&
      (!expectedButtonText || finalButtonText.includes(expectedButtonText));
    return {{
      ok,
      action: 'applied',
      detail: {{
        initialButtonText,
        finalButtonText,
        expectedButtonText,
        desiredValues,
        checkedBefore,
        checkedAfter,
        checkedAfterLabels,
        desiredCheckedLabels,
        exactSelection,
        desiredStates,
        actionsTaken,
        missingValues,
        closeMethod,
      }},
    }};
  }}

  const located = await locateQuickFilterFrame(Number(spec.tableau_wait_ms || 90000), spec.inner_frame_fragment || '');
  const frame = located.frame;
  const logs = [];
  const filters = {{}};
  for (const action of (spec.filter_actions || [])) {{
    if (action?.kind !== 'quick-filter') {{
      continue;
    }}
    try {{
      const result = await applyQuickFilter(frame, action);
      const label = String(action.label || '').trim();
      logs.push({{
        kind: 'quick-filter-ui',
        label: label.toLowerCase(),
        ok: result.ok,
        action: result.action,
        detail: result.detail,
      }});
      filters[label] = {{
        buttonText: String(result.detail?.finalButtonText || result.detail?.initialButtonText || '').trim(),
        value: String(result.detail?.finalButtonText || result.detail?.initialButtonText || '').trim(),
      }};
    }} catch (error) {{
      logs.push({{
        kind: 'quick-filter-ui',
        label: String(action.label || '').toLowerCase(),
        ok: false,
        error: String(error && error.message || error),
      }});
    }}
  }}

  if (Number(spec.post_apply_wait_ms || 0) > 0) {{
    await page.waitForTimeout(Number(spec.post_apply_wait_ms));
  }}

  const frameText = await readFrameText(frame);
  const matchedForbiddenPatterns = (spec.forbidden_text_patterns || [])
    .map((value) => String(value || '').trim())
    .filter(Boolean)
    .filter((value) => frameText.includes(value));
  if (matchedForbiddenPatterns.length > 0) {{
    logs.push({{
      kind: 'quick-filter-ui',
      label: '__frame_state__',
      ok: false,
      action: 'forbidden-text',
      detail: {{
        matchedForbiddenPatterns,
        frameTextExcerpt: frameText.slice(0, 2000),
      }},
    }});
  }}

  return {{
    frameUrl: frame.url(),
    frameCandidates: located.lastFrames,
    quickFilterHeadings: await collectQuickFilterHeadings(frame).catch(() => []),
    logs,
    filters,
    frameTextExcerpt: frameText.slice(0, 2000),
  }};
}}
"""


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


def parse_playwright_result(output: str) -> Any:
    """Extract the JSON payload from playwright-cli run-code output."""
    if "### Error" in output:
        raise RuntimeError(output.strip())
    marker = "### Result"
    start = output.find(marker)
    if start == -1:
        raise RuntimeError(f"Playwright output missing result marker: {output.strip()}")
    payload = output[start + len(marker):].lstrip()
    next_marker = payload.find("\n### ")
    if next_marker != -1:
        payload = payload[:next_marker]
    return json.loads(payload.strip())


def extract_playwright_snapshot_yaml(output: str) -> str:
    """Extract the YAML block from playwright-cli snapshot output."""
    marker = "### Snapshot"
    start = output.find(marker)
    if start == -1:
        return output
    code_start = output.find("```yaml", start)
    if code_start == -1:
        return output[start + len(marker):]
    code_start += len("```yaml")
    code_end = output.find("```", code_start)
    if code_end == -1:
        code_end = len(output)
    return output[code_start:code_end].strip()


def parse_quick_filter_snapshot(snapshot_yaml: str) -> dict[str, dict[str, Any]]:
    """Parse visible quick-filter button refs and current values from a snapshot."""
    heading_pattern = re.compile(r'^\s*-\s+heading "筛选器 (?P<label>.+?) 包含".*$')
    button_pattern = re.compile(r'^\s*-\s+button "筛选器 (?P<label>.+?) 包含".*?\[ref=(?P<ref>[^\]]+)\].*$')
    value_pattern = re.compile(r'^\s*-\s+generic \[ref=[^\]]+\]: (?P<value>.+)$')

    filters: dict[str, dict[str, Any]] = {}
    current_filter: str | None = None
    pending_value_filter: str | None = None

    for line in snapshot_yaml.splitlines():
        heading_match = heading_pattern.search(line)
        if heading_match:
            current_filter = heading_match.group("label").strip()
            filters.setdefault(current_filter, {})
            continue

        button_match = button_pattern.search(line)
        if button_match:
            current_filter = button_match.group("label").strip()
            filters.setdefault(current_filter, {})["button_ref"] = button_match.group("ref")
            pending_value_filter = current_filter
            continue

        if pending_value_filter:
            value_match = value_pattern.search(line)
            if value_match:
                filters.setdefault(pending_value_filter, {})["value"] = value_match.group("value").strip()
                pending_value_filter = None

    return filters


def parse_snapshot_checkboxes(snapshot_yaml: str) -> dict[str, dict[str, Any]]:
    """Parse labeled checkbox rows from an open Tableau quick-filter dialog snapshot."""
    checkbox_pattern = re.compile(
        r'^\s*-\s+checkbox "(?P<label>.+?)"(?: \[(?P<checked>checked)\])? \[ref=(?P<ref>[^\]]+)\]:$'
    )
    entries: dict[str, dict[str, Any]] = {}
    for line in snapshot_yaml.splitlines():
        match = checkbox_pattern.search(line)
        if not match:
            continue
        label = match.group("label").strip()
        entries[label] = {
            "label": label,
            "ref": match.group("ref"),
            "checked": match.group("checked") == "checked",
        }
    return entries


def find_quick_filter_state(
    filters: dict[str, dict[str, Any]],
    label: str,
) -> tuple[str, dict[str, Any]] | None:
    """Locate a quick-filter entry by label ignoring case."""
    normalized = str(label or "").strip().lower()
    for current_label, current_state in filters.items():
        if str(current_label).strip().lower() == normalized:
            return current_label, current_state
    return None


def snapshot_page_state(session: str, *, cwd: Path, timeout: int = 60) -> tuple[str, dict[str, dict[str, Any]]]:
    """Capture a playwright snapshot and parse visible quick filters."""
    snapshot_output = run_playwright(["--session", session, "snapshot"], cwd=cwd, timeout=timeout)
    snapshot_yaml = extract_playwright_snapshot_yaml(snapshot_output)
    return snapshot_yaml, parse_quick_filter_snapshot(snapshot_yaml)


def open_snapshot_quick_filter(
    session: str,
    *,
    cwd: Path,
    button_ref: str,
    timeout_seconds: int = 30,
) -> tuple[str, dict[str, dict[str, Any]], bool]:
    """Open a quick-filter dialog and return its checkbox rows."""
    started_at = time.monotonic()
    last_snapshot_yaml = ""
    last_checkboxes: dict[str, dict[str, Any]] = {}
    while time.monotonic() - started_at < timeout_seconds:
        run_playwright(["--session", session, "click", button_ref], cwd=cwd, timeout=60)
        time.sleep(1)
        last_snapshot_yaml, _ = snapshot_page_state(session, cwd=cwd, timeout=60)
        last_checkboxes = parse_snapshot_checkboxes(last_snapshot_yaml)
        if last_checkboxes:
            return last_snapshot_yaml, last_checkboxes, False
        if "无项目" in last_snapshot_yaml:
            return last_snapshot_yaml, last_checkboxes, True
        run_playwright(["--session", session, "press", "Escape"], cwd=cwd, timeout=60)
        time.sleep(1)
    raise RuntimeError(
        f"Failed to open quick filter dialog for ref={button_ref}: {last_snapshot_yaml[:2000]}"
    )


def set_snapshot_checkbox(session: str, *, cwd: Path, ref: str, checked: bool) -> None:
    """Set a quick-filter checkbox state by snapshot ref."""
    command = "check" if checked else "uncheck"
    try:
        run_playwright(["--session", session, command, ref], cwd=cwd, timeout=60)
    except subprocess.CalledProcessError:
        run_playwright(["--session", session, "click", ref], cwd=cwd, timeout=60)
    time.sleep(1)


def apply_snapshot_quick_filter(
    session: str,
    *,
    cwd: Path,
    action: dict[str, Any],
) -> dict[str, Any]:
    """Apply one S10-style quick filter using snapshot refs."""
    label = str(action.get("label") or "").strip()
    desired_values = [str(value).strip() for value in action.get("values", []) if str(value).strip()]
    expected_button_text = (
        str(action.get("expected_button_text") or "").strip()
        if "expected_button_text" in action
        else (desired_values[0] if len(desired_values) == 1 else ", ".join(desired_values))
    )

    snapshot_yaml, filters = snapshot_page_state(session, cwd=cwd)
    matched = find_quick_filter_state(filters, label)
    if matched is None:
        return {
            "kind": "quick-filter-snapshot",
            "label": label.lower(),
            "ok": False,
            "action": "missing-filter",
            "detail": {
                "label": label,
                "available_filters": filters,
                "snapshot_excerpt": snapshot_yaml[:2000],
            },
        }
    _, state = matched
    button_ref = str(state.get("button_ref") or "").strip()
    if not button_ref:
        return {
            "kind": "quick-filter-snapshot",
            "label": label.lower(),
            "ok": False,
            "action": "missing-button-ref",
            "detail": {
                "label": label,
                "state": state,
                "snapshot_excerpt": snapshot_yaml[:2000],
            },
        }

    opened_snapshot_yaml, checkboxes, empty_dialog = open_snapshot_quick_filter(
        session,
        cwd=cwd,
        button_ref=button_ref,
    )
    if empty_dialog:
        run_playwright(["--session", session, "press", "Escape"], cwd=cwd, timeout=60)
        time.sleep(1)
        return {
            "kind": "quick-filter-snapshot",
            "label": label.lower(),
            "ok": False,
            "action": "empty-dialog",
            "detail": {
                "label": label,
                "button_ref": button_ref,
                "desired_values": desired_values,
                "snapshot_excerpt": opened_snapshot_yaml[:2000],
            },
        }

    actions_taken: list[dict[str, Any]] = []
    all_entry = checkboxes.get("(全部)")
    if all_entry and all_entry.get("checked") and "(全部)" not in desired_values:
        set_snapshot_checkbox(session, cwd=cwd, ref=str(all_entry["ref"]), checked=False)
        actions_taken.append({"kind": "deselect-all", "label": "(全部)", "ref": all_entry["ref"]})

    for current_label, entry in list(checkboxes.items()):
        if current_label == "(全部)" or current_label in desired_values or not entry.get("checked"):
            continue
        set_snapshot_checkbox(session, cwd=cwd, ref=str(entry["ref"]), checked=False)
        actions_taken.append({"kind": "deselect", "label": current_label, "ref": entry["ref"]})

    missing_values: list[str] = []
    for desired_value in desired_values:
        entry = checkboxes.get(desired_value)
        if entry is None:
            missing_values.append(desired_value)
            continue
        if not entry.get("checked"):
            set_snapshot_checkbox(session, cwd=cwd, ref=str(entry["ref"]), checked=True)
            actions_taken.append({"kind": "select", "label": desired_value, "ref": entry["ref"]})

    refreshed_snapshot_yaml, _ = snapshot_page_state(session, cwd=cwd)
    checkboxes = parse_snapshot_checkboxes(refreshed_snapshot_yaml)

    run_playwright(["--session", session, "press", "Escape"], cwd=cwd, timeout=60)
    time.sleep(1)
    final_snapshot_yaml, final_filters = snapshot_page_state(session, cwd=cwd)
    matched_final = find_quick_filter_state(final_filters, label)
    final_value = ""
    if matched_final is not None:
        _, final_state = matched_final
        final_value = str(final_state.get("value") or "").strip()

    ok = not missing_values and final_value not in {"", "(无)"}
    if expected_button_text:
        ok = ok and expected_button_text in final_value

    return {
        "kind": "quick-filter-snapshot",
        "label": label.lower(),
        "ok": ok,
        "action": "applied",
        "detail": {
            "label": label,
            "desired_values": desired_values,
            "initial_value": str(state.get("value") or "").strip(),
            "final_value": final_value,
            "expected_button_text": expected_button_text,
            "missing_values": missing_values,
            "actions_taken": actions_taken,
            "checkboxes": checkboxes,
            "snapshot_excerpt": final_snapshot_yaml[:2000],
        },
    }


def apply_snapshot_quick_filters(
    session: str,
    *,
    cwd: Path,
    filter_actions: list[dict[str, Any]],
    forbidden_text_patterns: list[str] | None = None,
    post_apply_wait_ms: int = 0,
) -> dict[str, Any]:
    """Apply S10-style snapshot-driven quick filters for S04."""
    logs: list[dict[str, Any]] = []
    filters: dict[str, dict[str, Any]] = {}
    for action in filter_actions:
        if action.get("kind") != "quick-filter":
            continue
        result = apply_snapshot_quick_filter(session, cwd=cwd, action=action)
        logs.append(result)
        label = str(action.get("label") or "").strip()
        if label:
            filters[label] = {
                "value": str(result.get("detail", {}).get("final_value") or "").strip(),
                "buttonText": str(result.get("detail", {}).get("final_value") or "").strip(),
            }

    if post_apply_wait_ms > 0:
        time.sleep(post_apply_wait_ms / 1000)

    final_snapshot_yaml, final_filters = snapshot_page_state(session, cwd=cwd)
    matched_patterns = [
        pattern
        for pattern in (forbidden_text_patterns or [])
        if pattern and pattern in final_snapshot_yaml
    ]
    if matched_patterns:
        logs.append(
            {
                "kind": "quick-filter-snapshot",
                "label": "__frame_state__",
                "ok": False,
                "action": "forbidden-text",
                "detail": {
                    "matched_patterns": matched_patterns,
                    "snapshot_excerpt": final_snapshot_yaml[:2000],
                },
            }
        )

    for label, state in final_filters.items():
        filters.setdefault(
            label,
            {
                "value": str(state.get("value") or "").strip(),
                "buttonText": str(state.get("value") or "").strip(),
            },
        )

    return {
        "logs": logs,
        "filters": filters,
        "snapshot_excerpt": final_snapshot_yaml[:2000],
    }


def ensure_browser_session(
    session: str,
    *,
    cwd: Path,
    deadline: float | None = None,
    browser_config_path: Path | None = None,
) -> None:
    """Helper."""
    session_available = False
    try:
        run_playwright(
            ["--session", session, "tab-list"],
            cwd=cwd,
            timeout=remaining_timeout(45, deadline=deadline, action="checking existing browser session"),
        )
        session_available = True
    except subprocess.TimeoutExpired:
        session_available = False
    except subprocess.CalledProcessError as error:
        combined = f"{error.stdout}\n{error.stderr}"
        if "is not open" not in combined:
            raise
        session_available = False

    if session_available:
        return

    # Cleanup stale session state before opening a fresh browser session.
    try:
        run_playwright(
            ["--session", session, "close"],
            cwd=cwd,
            timeout=remaining_timeout(30, deadline=deadline, action="closing stale browser session"),
        )
    except Exception:
        pass

    args = ["--session", session, "open"]
    resolved_browser_config_path = browser_config_path or PLAYWRIGHT_CLI_CONFIG_PATH
    if resolved_browser_config_path is not None:
        args.extend(["--config", str(resolved_browser_config_path)])
    args.append("about:blank")
    run_playwright(
        args,
        cwd=cwd,
        timeout=remaining_timeout(90, deadline=deadline, action="opening the browser session"),
    )


def save_state(session: str, state_path: Path, *, cwd: Path, deadline: float | None = None) -> None:
    """Helper."""
    state_path.parent.mkdir(parents=True, exist_ok=True)
    run_playwright(
        ["--session", session, "state-save", str(state_path)],
        cwd=cwd,
        timeout=remaining_timeout(60, deadline=deadline, action="saving the browser state"),
    )


def load_state_if_present(
    session: str,
    state_path: Path,
    *,
    cwd: Path,
    deadline: float | None = None,
) -> None:
    """Helper."""
    if not state_path.exists():
        return
    run_playwright(
        ["--session", session, "state-load", str(state_path)],
        cwd=cwd,
        timeout=remaining_timeout(60, deadline=deadline, action="loading the browser state"),
    )


def locate_session_file(root: Path, session: str, filename: str) -> Path:
    """Helper."""
    direct = resolve_session_capture_path(root, session, filename)
    if direct.exists():
        return direct
    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 wait_for_capture_file(
    filename: str,
    *,
    workdir: Path,
    workspace_root: Path,
    session: str,
    timeout_seconds: int = 45,
    preferred_path: Path | None = None,
    deadline: float | None = None,
) -> Path:
    """Helper."""
    effective_timeout = remaining_timeout(
        timeout_seconds,
        deadline=deadline,
        action=f"waiting for screenshot {filename}",
    )
    wait_deadline = time.monotonic() + effective_timeout
    local_output = workdir / filename
    session_output = resolve_session_capture_path(workspace_root, session, filename)
    while time.monotonic() < wait_deadline:
        if preferred_path is not None and preferred_path.exists():
            return preferred_path
        if local_output.exists():
            return local_output
        if session_output.exists():
            return session_output
        time.sleep(1)
    raise FileNotFoundError(f"Unable to locate {filename} under {workspace_root}.")


def validate_downloaded_image(
    image_path: Path,
    capture_spec: dict[str, Any],
) -> str | None:
    """Return an error message when a downloaded Tableau image is clearly incomplete."""
    min_size = int(capture_spec.get("min_download_file_size_bytes", 0) or 0)
    if min_size > 0:
        actual_size = image_path.stat().st_size
        if actual_size < min_size:
            return (
                f"Downloaded image for {capture_spec['capture_id']} is too small: "
                f"{actual_size} bytes < {min_size} bytes."
            )
    return None


def parse_args() -> argparse.Namespace:
    """Helper."""
    parser = argparse.ArgumentParser(
        description="Capture CK Inventory Monthly S04-S08 Tableau assets."
    )
    parser.add_argument(
        "--config",
        default=r"C:\Users\niuniu\.codex\vip-report\config.yaml",
        help="Path to the VIP report config.yaml file.",
    )
    parser.add_argument(
        "--slides",
        default="S04,S05,S06,S07,S08",
        help="Comma-separated slide codes to generate.",
    )
    parser.add_argument(
        "--report-month",
        default="",
        help="Report month filter, for example 二月 / 2 / 02.",
    )
    parser.add_argument(
        "--report-year",
        type=int,
        default=0,
        help="Report year filter, for example 2026.",
    )
    parser.add_argument(
        "--compare-year",
        type=int,
        default=0,
        help="Comparison year passed to the Tableau year2 parameter.",
    )
    parser.add_argument(
        "--disable-template-lock",
        action="store_true",
        help="Disable locked template assets and force live Tableau capture.",
    )
    parser.add_argument(
        "--output-dir",
        default="",
        help="Override the output/work directory used for generated assets and ops.",
    )
    parser.add_argument(
        "--playwright-session",
        default="",
        help="Override the shared Playwright session name used for Tableau capture.",
    )
    parser.add_argument(
        "--tableau-state-path",
        default="",
        help="Override the shared Playwright storage-state path used for Tableau login reuse.",
    )
    parser.add_argument(
        "--browser-config-path",
        default="",
        help="Optional Playwright CLI config path used when opening a new shared browser session.",
    )
    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 capture_tableau_view(
    capture_spec: dict[str, Any],
    *,
    base_url: str,
    session: str,
    workdir: Path,
    workspace_root: Path,
    download_dir: Path | None = None,
    deadline: float | None = None,
) -> Path:
    """Helper."""
    target_url = f"{base_url}{capture_spec['hash_url']}"
    viewport = capture_spec.get("viewport", VIEWPORT)
    raw_output_path = (workdir / capture_spec["raw_screenshot_name"]).resolve()
    raw_output_path.unlink(missing_ok=True)
    open_started_at = time.monotonic()
    run_playwright(
        ["--session", session, "goto", target_url],
        cwd=workdir,
        timeout=remaining_timeout(120, deadline=deadline, action=f"opening {capture_spec['capture_id']}"),
    )
    run_playwright(
        ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
        cwd=workdir,
        timeout=remaining_timeout(60, deadline=deadline, action=f"resizing {capture_spec['capture_id']}"),
    )
    log_timing(f"open {capture_spec['capture_id']}", open_started_at, deadline=deadline)

    capture_mode = capture_spec.get("capture_mode", "frame_body")
    ui_filter_mode = str(capture_spec.get("ui_filter_mode", "script"))
    preferred_capture_path = raw_output_path
    configure_script: Path | None = None
    capture_script: Path | None = None
    ui_filter_script: Path | None = None
    if capture_mode == "download_image":
        preferred_capture_path = resolve_playwright_download_path(capture_spec["raw_screenshot_name"], download_dir)
        preferred_capture_path.unlink(missing_ok=True)
        configure_script = write_js(
            workdir,
            f"tmp-{capture_spec['capture_id']}-configure.js",
            build_configure_view_js(
                {
                    "activate_sheet": capture_spec["activate_sheet"],
                    "filters": capture_spec["filters"],
                    "params": capture_spec["params"],
                    "viewport": capture_spec.get("viewport", VIEWPORT),
                    "inner_frame_fragment": capture_spec.get("inner_frame_fragment", ""),
                    "tableau_wait_ms": int(capture_spec.get("tableau_wait_ms", 60000)),
                    "post_filter_wait_ms": int(capture_spec.get("post_filter_wait_ms", 1500)),
                    "tableau_context": str(capture_spec.get("tableau_context", "frame")),
                    "filter_apply_mode": str(capture_spec.get("filter_apply_mode", "per_filter")),
                }
            ),
        )
        capture_script = write_js(
            workdir,
            f"tmp-{capture_spec['capture_id']}-capture.js",
            build_export_image_js(
                capture_spec["inner_frame_fragment"],
                str(preferred_capture_path),
                viewport=capture_spec.get("viewport", VIEWPORT),
                tableau_wait_ms=int(capture_spec.get("tableau_wait_ms", 60000)),
                download_wait_ms=int(capture_spec.get("download_wait_ms", 60000)),
            ),
        )
        if capture_spec.get("ui_filter_actions") and ui_filter_mode != "snapshot":
            ui_filter_script = write_js(
                workdir,
                f"tmp-{capture_spec['capture_id']}-quick-filters.js",
                build_apply_quick_filters_js(
                    {
                        "filter_actions": capture_spec.get("ui_filter_actions", []),
                        "inner_frame_fragment": capture_spec.get("inner_frame_fragment", ""),
                        "tableau_wait_ms": int(capture_spec.get("tableau_wait_ms", 60000)),
                        "post_apply_wait_ms": int(capture_spec.get("ui_filter_post_wait_ms", 0)),
                        "forbidden_text_patterns": capture_spec.get(
                            "ui_filter_forbidden_text_patterns",
                            [],
                        ),
                    }
                ),
            )
    else:
        configure_script = write_js(
            workdir,
            f"tmp-{capture_spec['capture_id']}-configure.js",
            build_configure_view_js(
                {
                    "activate_sheet": capture_spec["activate_sheet"],
                    "filters": capture_spec["filters"],
                    "params": capture_spec["params"],
                    "viewport": capture_spec.get("viewport", VIEWPORT),
                    "inner_frame_fragment": capture_spec.get("inner_frame_fragment", ""),
                    "tableau_wait_ms": int(capture_spec.get("tableau_wait_ms", 60000)),
                    "post_filter_wait_ms": int(capture_spec.get("post_filter_wait_ms", 7000)),
                }
            ),
        )
        capture_script = write_js(
            workdir,
            f"tmp-{capture_spec['capture_id']}-capture.js",
            build_capture_js(
                capture_spec["inner_frame_fragment"],
                capture_spec["raw_screenshot_name"],
                ready_patterns=capture_spec.get("ready_patterns"),
                viewport=capture_spec.get("viewport", VIEWPORT),
                capture_mode=capture_mode,
                scroll_selector=capture_spec.get("scroll_selector"),
                scroll_top=capture_spec.get("scroll_top"),
                capture_wait_ms=int(capture_spec.get("capture_wait_ms", 3000)),
            ),
        )

    try:
        max_attempts = 1 if deadline is not None else 5
        last_error: str | None = None
        captured_path: Path | None = None
        for attempt in range(1, max_attempts + 1):
            if capture_mode == "download_image":
                try:
                    configure_started_at = time.monotonic()
                    configure_output = run_code(
                        session,
                        configure_script,
                        cwd=workdir,
                        timeout=remaining_timeout(
                            420,
                            deadline=deadline,
                            action=f"configuring {capture_spec['capture_id']}",
                        ),
                    ).strip()
                    log_timing(
                        f"configure {capture_spec['capture_id']} attempt {attempt}",
                        configure_started_at,
                        deadline=deadline,
                    )
                except subprocess.TimeoutExpired as exc:
                    last_error = f"TimeoutExpired: {exc}"
                    configure_output = f"### Error\nError: {last_error}"
                if "### Error" in configure_output:
                    last_error = configure_output
                    if attempt < max_attempts:
                        run_playwright(
                            ["--session", session, "goto", target_url],
                            cwd=workdir,
                            timeout=remaining_timeout(
                                120,
                                deadline=deadline,
                                action=f"reopening {capture_spec['capture_id']}",
                            ),
                        )
                        run_playwright(
                            ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
                            cwd=workdir,
                            timeout=remaining_timeout(
                                60,
                                deadline=deadline,
                                action=f"resizing {capture_spec['capture_id']} after retry",
                            ),
                        )
                        time.sleep(3)
                        continue
                    raise RuntimeError(
                        f"Failed to configure Tableau view for {capture_spec['capture_id']}: {last_error}"
                    )

                if capture_spec.get("ui_filter_actions"):
                    try:
                        ui_filter_started_at = time.monotonic()
                        if ui_filter_mode == "snapshot":
                            ui_filter_result = apply_snapshot_quick_filters(
                                session,
                                cwd=workdir,
                                filter_actions=capture_spec.get("ui_filter_actions", []),
                                forbidden_text_patterns=capture_spec.get(
                                    "ui_filter_forbidden_text_patterns",
                                    [],
                                ),
                                post_apply_wait_ms=int(capture_spec.get("ui_filter_post_wait_ms", 0)),
                            )
                            ui_filter_output = json.dumps(ui_filter_result, ensure_ascii=False)
                        else:
                            ui_filter_output = run_code(
                                session,
                                ui_filter_script,
                                cwd=workdir,
                                timeout=remaining_timeout(
                                    240,
                                    deadline=deadline,
                                    action=f"applying quick filters for {capture_spec['capture_id']}",
                                ),
                            ).strip()
                            ui_filter_result = parse_playwright_result(ui_filter_output)
                        log_timing(
                            f"quick filters {capture_spec['capture_id']} attempt {attempt}",
                            ui_filter_started_at,
                            deadline=deadline,
                        )
                    except subprocess.TimeoutExpired as exc:
                        last_error = f"TimeoutExpired: {exc}"
                        ui_filter_output = f"### Error\nError: {last_error}"
                    except Exception as exc:  # noqa: BLE001
                        last_error = str(exc)
                        ui_filter_output = f"### Error\nError: {last_error}"
                    if "### Error" in ui_filter_output:
                        last_error = ui_filter_output
                        if attempt < max_attempts:
                            run_playwright(
                                ["--session", session, "goto", target_url],
                                cwd=workdir,
                                timeout=remaining_timeout(
                                    120,
                                    deadline=deadline,
                                    action=f"reopening {capture_spec['capture_id']}",
                                ),
                            )
                            run_playwright(
                                ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
                                cwd=workdir,
                                timeout=remaining_timeout(
                                    60,
                                    deadline=deadline,
                                    action=f"resizing {capture_spec['capture_id']} after quick-filter retry",
                                ),
                            )
                            time.sleep(3)
                            continue
                        raise RuntimeError(
                            f"Failed to apply Tableau quick filters for {capture_spec['capture_id']}: {last_error}"
                        )
                    failed_quick_filters = [item for item in ui_filter_result.get("logs", []) if not item.get("ok")]
                    if failed_quick_filters:
                        last_error = json.dumps(ui_filter_result, ensure_ascii=False)
                        if attempt < max_attempts:
                            run_playwright(
                                ["--session", session, "goto", target_url],
                                cwd=workdir,
                                timeout=remaining_timeout(
                                    120,
                                    deadline=deadline,
                                    action=f"reopening {capture_spec['capture_id']}",
                                ),
                            )
                            run_playwright(
                                ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
                                cwd=workdir,
                                timeout=remaining_timeout(
                                    60,
                                    deadline=deadline,
                                    action=f"resizing {capture_spec['capture_id']} after quick-filter retry",
                                ),
                            )
                            time.sleep(3)
                            continue
                        raise RuntimeError(
                            f"Failed to apply Tableau quick filters for {capture_spec['capture_id']}: {last_error}"
                        )

                export_attempts = int(
                    capture_spec.get(
                        "download_validation_max_attempts",
                        1 if deadline is not None else 1,
                    )
                )
                retry_interval_ms = int(
                    capture_spec.get(
                        "download_validation_retry_interval_ms",
                        INVENTORY_MONTHLY_DOWNLOAD_VALIDATION_RETRY_INTERVAL_MS,
                    )
                )
                for export_attempt in range(1, export_attempts + 1):
                    preferred_capture_path.unlink(missing_ok=True)
                    raw_output_path.unlink(missing_ok=True)
                    if capture_script is None or not capture_script.exists():
                        capture_script = write_js(
                            workdir,
                            f"tmp-{capture_spec['capture_id']}-capture.js",
                            build_export_image_js(
                                capture_spec["inner_frame_fragment"],
                                str(preferred_capture_path),
                                viewport=capture_spec.get("viewport", VIEWPORT),
                                tableau_wait_ms=int(capture_spec.get("tableau_wait_ms", 60000)),
                                download_wait_ms=int(capture_spec.get("download_wait_ms", 60000)),
                            ),
                        )
                    try:
                        capture_started_at = time.monotonic()
                        capture_output = run_code(
                            session,
                            capture_script,
                            cwd=workdir,
                            timeout=remaining_timeout(
                                420,
                                deadline=deadline,
                                action=f"capturing {capture_spec['capture_id']}",
                            ),
                        )
                        log_timing(
                            f"capture {capture_spec['capture_id']} export {export_attempt}",
                            capture_started_at,
                            deadline=deadline,
                        )
                    except subprocess.TimeoutExpired as exc:
                        last_error = f"TimeoutExpired: {exc}"
                        break
                    if "### Error" in capture_output:
                        last_error = capture_output
                        break

                    wait_started_at = time.monotonic()
                    candidate_path = wait_for_capture_file(
                        capture_spec["raw_screenshot_name"],
                        workdir=workdir,
                        workspace_root=workspace_root,
                        session=session,
                        timeout_seconds=45,
                        preferred_path=preferred_capture_path,
                        deadline=deadline,
                    )
                    log_timing(
                        f"wait file {capture_spec['capture_id']} export {export_attempt}",
                        wait_started_at,
                        deadline=deadline,
                    )
                    validation_error = validate_downloaded_image(candidate_path, capture_spec)
                    if validation_error is None:
                        captured_path = candidate_path
                        break
                    last_error = validation_error
                    candidate_path.unlink(missing_ok=True)
                    if export_attempt < export_attempts:
                        time.sleep(max(0, retry_interval_ms) / 1000)

                if captured_path is not None:
                    break

                if attempt < max_attempts:
                    run_playwright(
                        ["--session", session, "goto", target_url],
                        cwd=workdir,
                        timeout=remaining_timeout(
                            120,
                            deadline=deadline,
                            action=f"reopening {capture_spec['capture_id']}",
                        ),
                    )
                    run_playwright(
                        ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
                        cwd=workdir,
                        timeout=remaining_timeout(
                            60,
                            deadline=deadline,
                            action=f"resizing {capture_spec['capture_id']} after retry",
                        ),
                    )
                    time.sleep(3)
                    continue
                raise RuntimeError(
                    f"Failed to capture Tableau view for {capture_spec['capture_id']}: {last_error}"
                )
            else:
                try:
                    configure_started_at = time.monotonic()
                    configure_output = run_code(
                        session,
                        configure_script,
                        cwd=workdir,
                        timeout=remaining_timeout(
                            420,
                            deadline=deadline,
                            action=f"configuring {capture_spec['capture_id']}",
                        ),
                    ).strip()
                    log_timing(
                        f"configure {capture_spec['capture_id']} attempt {attempt}",
                        configure_started_at,
                        deadline=deadline,
                    )
                except subprocess.TimeoutExpired as exc:
                    last_error = f"TimeoutExpired: {exc}"
                    configure_output = f"### Error\nError: {last_error}"
                if "### Error" in configure_output:
                    last_error = configure_output
                    if attempt < max_attempts:
                        run_playwright(
                            ["--session", session, "goto", target_url],
                            cwd=workdir,
                            timeout=remaining_timeout(
                                120,
                                deadline=deadline,
                                action=f"reopening {capture_spec['capture_id']}",
                            ),
                        )
                        run_playwright(
                            ["--session", session, "resize", str(viewport["width"]), str(viewport["height"])],
                            cwd=workdir,
                            timeout=remaining_timeout(
                                60,
                                deadline=deadline,
                                action=f"resizing {capture_spec['capture_id']} after retry",
                            ),
                        )
                        time.sleep(3)
                        continue
                    raise RuntimeError(
                        f"Failed to configure Tableau view for {capture_spec['capture_id']}: {last_error}"
                    )
                capture_started_at = time.monotonic()
                run_code(
                    session,
                    capture_script,
                    cwd=workdir,
                    timeout=remaining_timeout(
                        180,
                        deadline=deadline,
                        action=f"capturing {capture_spec['capture_id']}",
                    ),
                )
                log_timing(
                    f"capture {capture_spec['capture_id']} attempt {attempt}",
                    capture_started_at,
                    deadline=deadline,
                )
                break
    finally:
        if configure_script is not None:
            configure_script.unlink(missing_ok=True)
        if ui_filter_script is not None:
            ui_filter_script.unlink(missing_ok=True)
        if capture_script is not None:
            capture_script.unlink(missing_ok=True)

    if captured_path is None:
        wait_started_at = time.monotonic()
        captured_path = wait_for_capture_file(
            capture_spec["raw_screenshot_name"],
            workdir=workdir,
            workspace_root=workspace_root,
            session=session,
            timeout_seconds=45,
            preferred_path=preferred_capture_path,
            deadline=deadline,
        )
        log_timing(f"wait final file {capture_spec['capture_id']}", wait_started_at, deadline=deadline)
    if captured_path != raw_output_path:
        raw_output_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(captured_path, raw_output_path)
        captured_path = raw_output_path
    normalize_size = capture_spec.get("normalize_download_size")
    if capture_mode == "download_image" and normalize_size:
        normalize_image_size(captured_path, normalize_size)
    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)
    specs = build_specs(report_month, report_year, compare_year)

    requested = parse_requested_slides(args.slides)
    filtered_assets = [item for item in specs["assets"] if item["slide_code"] in requested]
    if not filtered_assets:
        raise SystemExit("No matching S04-S08 assets were 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" / "inventory-monthly"
    data_dir = vip_workdir / "data" / "inventory-monthly"
    download_dir = vip_workdir / ".playwright-downloads" if args.output_dir else None
    asset_dir.mkdir(parents=True, exist_ok=True)
    data_dir.mkdir(parents=True, exist_ok=True)
    if download_dir is not None:
        download_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_slide_codes = build_capture_slide_codes(filtered_assets)
    use_template_lock = (
        False
        if args.disable_template_lock
        else should_use_template_lock(report_month, report_year, compare_year)
    )
    log_progress("stage", f"start inventory-monthly slides={','.join(sorted(requested))}")

    raw_screenshots: dict[str, Path] = {}
    if not use_template_lock:
        run_deadline = resolve_deadline(
            SINGLE_SLIDE_TIMEOUT_SECONDS if should_use_single_slide_timeout(requested) else None
        )
        session = args.playwright_session.strip() if args.playwright_session.strip() else (
            f"{SESSION_NAME}-{vip_workdir.name}-{int(time.time())}"
            if args.output_dir
            else f"{SESSION_NAME}-{int(time.time())}"
        )
        state_path = (
            Path(args.tableau_state_path).expanduser().resolve()
            if args.tableau_state_path.strip()
            else (
                vip_workdir / ".playwright-cli" / f"{SESSION_NAME}-state.json"
                if args.output_dir
                else workspace_root / "output" / "playwright" / SESSION_NAME / "state.json"
            )
        )
        browser_config_path = (
            Path(args.browser_config_path).expanduser().resolve()
            if args.browser_config_path.strip()
            else PLAYWRIGHT_CLI_CONFIG_PATH
        )

        browser_started_at = time.monotonic()
        ensure_browser_session(
            session,
            cwd=vip_workdir,
            deadline=run_deadline,
            browser_config_path=browser_config_path,
        )
        log_timing("open browser session", browser_started_at, deadline=run_deadline)

        load_state_started_at = time.monotonic()
        load_state_if_present(session, state_path, cwd=vip_workdir, deadline=run_deadline)
        if state_path.exists():
            log_timing("load browser state", load_state_started_at, deadline=run_deadline)

        base_url = config["tableau"]["base_url"].rstrip("/")
        first_capture = captures_by_id[next(iter(sorted(required_capture_ids)))]
        first_target_url = f"{base_url}{first_capture['hash_url']}"
        first_open_started_at = time.monotonic()
        first_goto_output = run_playwright(
            ["--session", session, "goto", first_target_url],
            cwd=vip_workdir,
            timeout=remaining_timeout(120, deadline=run_deadline, action="opening the first Tableau view"),
        )
        log_timing("open first Tableau view", first_open_started_at, deadline=run_deadline)
        if "### Error" in first_goto_output:
            raise RuntimeError(f"Failed to open the first Tableau view: {first_goto_output.strip()}")

        login_script = write_js(
            vip_workdir,
            "tmp-inventory-monthly-login.js",
            build_login_js(config["tableau"]["username"], config["tableau"]["password"]),
        )
        try:
            login_started_at = time.monotonic()
            run_code(
                session,
                login_script,
                cwd=vip_workdir,
                timeout=remaining_timeout(120, deadline=run_deadline, action="logging in to Tableau"),
            )
            log_timing("login Tableau", login_started_at, deadline=run_deadline)
        finally:
            login_script.unlink(missing_ok=True)

        for capture_id in sorted(required_capture_ids):
            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,
                download_dir=download_dir,
                deadline=run_deadline,
            )
            log_timing(f"page {page_label}", page_started_at, deadline=run_deadline)

        try:
            save_state(session, state_path, cwd=vip_workdir, deadline=run_deadline)
        except (TimeoutError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
            pass

    operations = {"replace_text": [], "replace_images": []}
    manifest_items: list[dict[str, Any]] = []
    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"
        if use_template_lock:
            # 鍩虹嚎鏈熶紭鍏堝鐢ㄦā鏉块攣瀹氬浘锛岀‘淇濅笌妯℃澘瑙嗚涓€鑷淬€?
            source_path = TEMPLATE_LOCK_DIR / f"s{asset['slide']}_shape{asset['shape_id']}.png"
            if not source_path.exists():
                raise FileNotFoundError(f"Template lock image not found: {source_path}")
            shutil.copyfile(source_path, target)
        else:
            source_path = raw_screenshots[asset["capture_id"]]
            crop_image(
                source_path,
                target,
                asset["crop"],
                resize_to=asset.get("resize_to"),
            )

        if not use_template_lock:
            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": "template-lock" if use_template_lock else asset["capture_id"],
            "source_view": "template-lock" if use_template_lock else 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"]
        manifest_items.append(manifest_item)
        log_timing(f"asset {asset_label}", asset_started_at)
    log_timing(f"assets inventory-monthly {','.join(sorted(requested))}", assets_started_at)

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

    manifest_path = data_dir / "inventory-monthly-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 sorted(raw_screenshots)
                    ],
                    "config_path": str(config_path),
                    "report_period": {
                        "month": report_month,
                        "year": report_year,
                        "compare_year": compare_year,
                    },
                },
                "assets": manifest_items,
                "operations_path": str(operations_path),
            },
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    log_timing(f"stage inventory-monthly {','.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()
