from __future__ import annotations

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

import yaml

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from runtime_config import add_config_source_args, load_runtime_config


NPX_EXECUTABLE = shutil.which("npx.cmd") or shutil.which("npx") or "npx"
PLAYWRIGHT_CMD = [NPX_EXECUTABLE, "--yes", "--package", "@playwright/cli", "playwright-cli"]
SESSION_NAME = "vip-report-monthly-sales"
TARGET_URL = "https://vis.vip.com/#/app-i/BCT/vendor_new_v2/index.html#/frontend/memberOverview"


def run_cmd(args: list[str], *, cwd: Path, timeout: int = 300, check: bool = True) -> subprocess.CompletedProcess[str]:
    env = os.environ.copy()
    npm_cache = cwd / ".npm-cache"
    pw_daemon_dir = cwd / ".pw-daemon"
    npm_cache.mkdir(parents=True, exist_ok=True)
    pw_daemon_dir.mkdir(parents=True, exist_ok=True)
    env["NPM_CONFIG_CACHE"] = str(npm_cache)
    env["PLAYWRIGHT_DAEMON_SESSION_DIR"] = str(pw_daemon_dir)
    return subprocess.run(
        args,
        cwd=str(cwd),
        text=True,
        encoding="utf-8",
        errors="replace",
        capture_output=True,
        timeout=timeout,
        check=check,
        env=env,
    )


def run_playwright(args: list[str], *, cwd: Path, timeout: int = 300, auto_open: bool = False) -> str:
    def _get_session_name(cli_args: list[str]) -> str:
        try:
            idx = cli_args.index("--session")
            if idx + 1 < len(cli_args):
                return str(cli_args[idx + 1])
        except ValueError:
            pass
        return SESSION_NAME

    try:
        result = run_cmd(PLAYWRIGHT_CMD + args, cwd=cwd, timeout=timeout)
        return result.stdout
    except subprocess.CalledProcessError as e:
        session_name = _get_session_name(args)
        out_text = f"{(e.stdout or '').strip()}\n{(e.stderr or '').strip()}"
        session_not_open = ("is not open, please run open first" in out_text) or ("is not open. Run" in out_text)
        if session_not_open and not auto_open:
            raise RuntimeError(
                "PLAYWRIGHT_SESSION_NOT_OPEN\n"
                "请先手工打开已登录会话后再执行脚本。\n"
                f"session={session_name}\n"
                f"args={' '.join(PLAYWRIGHT_CMD + args)}\n"
                f"stdout=\n{(e.stdout or '').strip()}\n"
                f"stderr=\n{(e.stderr or '').strip()}\n"
            )
        if session_not_open and auto_open:
            profile_dir = cwd / ".chrome-user-data"
            profile_dir.mkdir(parents=True, exist_ok=True)

            def _cleanup_stale_daemon(error_text: str) -> None:
                # Example: "Daemon pid=20564: listen EADDRINUSE ..."
                m = re.search(r"Daemon pid=(\d+)", error_text or "")
                if m:
                    pid = m.group(1)
                    subprocess.run(
                        ["taskkill", "/PID", pid, "/F", "/T"],
                        text=True,
                        encoding="utf-8",
                        errors="replace",
                        capture_output=True,
                        check=False,
                    )
                run_cmd(PLAYWRIGHT_CMD + ["close-all"], cwd=cwd, timeout=60, check=False)
                time.sleep(1.0)

            open_proc = run_cmd(
                PLAYWRIGHT_CMD
                + [
                    "--session",
                    session_name,
                    "open",
                    "--browser",
                    "chrome",
                    "--headed",
                    "--persistent",
                    "--profile",
                    str(profile_dir),
                    TARGET_URL,
                ],
                cwd=cwd,
                timeout=120,
                check=False,
            )
            open_text = f"{(open_proc.stdout or '').strip()}\n{(open_proc.stderr or '').strip()}"
            if "EADDRINUSE" in open_text or "address already in use" in open_text:
                # Stale daemon/socket: kill daemon pid (if present), then close all and retry open once.
                _cleanup_stale_daemon(open_text)
                open_proc = run_cmd(
                    PLAYWRIGHT_CMD
                    + [
                        "--session",
                        session_name,
                        "open",
                        "--browser",
                        "chrome",
                        "--headed",
                        "--persistent",
                        "--profile",
                        str(profile_dir),
                        TARGET_URL,
                    ],
                    cwd=cwd,
                    timeout=120,
                    check=False,
                )
                open_text = f"{(open_proc.stdout or '').strip()}\n{(open_proc.stderr or '').strip()}"
            if open_proc.returncode != 0 and ("EADDRINUSE" in open_text or "address already in use" in open_text):
                # one more hard cleanup + open retry
                _cleanup_stale_daemon(open_text)
                open_proc = run_cmd(
                    PLAYWRIGHT_CMD
                    + [
                        "--session",
                        session_name,
                        "open",
                        "--browser",
                        "chrome",
                        "--headed",
                        "--persistent",
                        "--profile",
                        str(profile_dir),
                        TARGET_URL,
                    ],
                    cwd=cwd,
                    timeout=120,
                    check=False,
                )
            time.sleep(2.0)
            retry = run_cmd(PLAYWRIGHT_CMD + args, cwd=cwd, timeout=timeout, check=False)
            if retry.returncode == 0:
                return retry.stdout
            raise RuntimeError(
                "playwright-cli retry failed after auto-open\n"
                f"args={' '.join(PLAYWRIGHT_CMD + args)}\n"
                f"open_returncode={open_proc.returncode}\n"
                f"open_stdout=\n{(open_proc.stdout or '').strip()}\n"
                f"open_stderr=\n{(open_proc.stderr or '').strip()}\n"
                f"retry_returncode={retry.returncode}\n"
                f"retry_stdout=\n{(retry.stdout or '').strip()}\n"
                f"retry_stderr=\n{(retry.stderr or '').strip()}\n"
            )
        raise RuntimeError(
            "playwright-cli failed\n"
            f"args={' '.join(PLAYWRIGHT_CMD + args)}\n"
            f"returncode={e.returncode}\n"
            f"stdout=\n{(e.stdout or '').strip()}\n"
            f"stderr=\n{(e.stderr or '').strip()}\n"
        )


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


def run_code(session: str, script_path: Path, *, cwd: Path, timeout: int = 420, auto_open: bool = False) -> str:
    return run_playwright(
        ["--session", session, "run-code", "--filename", str(script_path)],
        cwd=cwd,
        timeout=timeout,
        auto_open=auto_open,
    )


def parse_month_number(raw: Any) -> int:
    if raw is None:
        return 1
    if isinstance(raw, int):
        return min(12, max(1, raw))
    text = str(raw).strip()
    m = re.search(r"(1[0-2]|0?[1-9])", text)
    if m:
        return int(m.group(1))
    return 1


def resolve_period(cfg: dict[str, Any], report_year_arg: int, report_month_arg: str) -> tuple[int, int]:
    report_cfg = cfg.get("report", {})
    year = int(report_year_arg or report_cfg.get("year", 2026))
    month = parse_month_number(report_month_arg or report_cfg.get("month_cn", "1"))
    return year, month


def month_last_day(year: int, month: int) -> int:
    import calendar
    return calendar.monthrange(year, month)[1]


def build_capture_js(spec: dict[str, Any]) -> str:
    payload = json.dumps(spec, ensure_ascii=False)
    return f"""async function(page) {{
  const spec = {payload};
  const sleep = (ms) => page.waitForTimeout(ms);
  const currentUrl = page.url() || '';
  if (!currentUrl.includes('/frontend/memberOverview')) {{
    await page.goto(spec.url, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
  }} else {{
    await page.reload({{ waitUntil: 'domcontentloaded', timeout: 120000 }}).catch(() => null);
  }}
  await sleep(6000);

  function visible(el) {{
    if (!el) return false;
    const s = getComputedStyle(el);
    const r = el.getBoundingClientRect();
    return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 1 && r.height > 1;
  }}
  function text(el) {{
    return ((el && el.textContent) || '').replace(/\\s+/g, ' ').trim();
  }}
  async function resolveContext() {{
    for (let i = 0; i < 20; i++) {{
      const frames = page.frames().filter((fr) => fr.url().includes('bct.vip.com') && fr.url().includes('/frontend/memberOverview'));
      for (const f of frames) {{
        try {{
          const fe = await f.frameElement();
          const fb = await fe.boundingBox();
          if (fb && fb.width > 300 && fb.height > 200) return f;
        }} catch {{}}
      }}
      if (frames.length) return frames[0];
      await sleep(500);
    }}
    return page;
  }}

  const ctx = await resolveContext();
  await sleep(1500);
  // wait loading mask gone (best effort)
  for (let i = 0; i < 30; i++) {{
    const busy = await ctx.evaluate(() => {{
      const masks = Array.from(document.querySelectorAll('.el-loading-mask,.v-loading-mask,.loading-mask,[class*="loading"]'))
        .filter((el) => {{
          const s = getComputedStyle(el);
          const r = el.getBoundingClientRect();
          return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 20 && r.height > 20;
        }});
      return masks.length > 0;
    }}).catch(() => false);
    if (!busy) break;
    await sleep(800);
  }}

  // Force period type = 自然月, then set configured month and trigger query.
  let clickedNatural = false;
  let clickedMonth = false;
  let clickedQuery = false;
  let typeValue = "";
  let monthValue = "";
  const ym = `${{spec.year}}-${{String(spec.month).padStart(2, "0")}}`;
  const clickNaturalOption = async (scope) => {{
    const opts = scope.locator("li.el-select-dropdown__item, .el-select-dropdown__item");
    const exact = opts.filter({{ hasText: "\\u81ea\\u7136\\u6708" }}).first();
    if (await exact.count().catch(() => 0)) {{
      await exact.click({{ timeout: 5000 }}).catch(() => null);
      return true;
    }}
    const optCount = await opts.count().catch(() => 0);
    for (let i = 0; i < optCount; i++) {{
      const t = ((await opts.nth(i).innerText().catch(() => "")) || "").replace(/\\s+/g, "");
      if (t.includes("月") && !t.includes("日")) {{
        await opts.nth(i).click({{ timeout: 5000 }}).catch(() => null);
        return true;
      }}
    }}
    return false;
  }};
  const readTypeValue = async () => {{
    let val = "";
    try {{
      const typeInput = ctx.locator(".dt-type-selector input.el-input__inner, .dt-type-selector .el-input__inner").first();
      val = (await typeInput.inputValue().catch(() => "")) || "";
      if (!val) {{
        val = (await typeInput.innerText().catch(() => "")) || "";
      }}
      if (!val) {{
        val = await ctx.evaluate(() => {{
          const el = document.querySelector(".dt-type-selector input.el-input__inner, .dt-type-selector .el-input__inner");
          if (!el) return "";
          return (el.value || el.textContent || "").replace(/\\s+/g, " ").trim();
        }}).catch(() => "");
      }}
    }} catch {{}}
    return (val || "").replace(/\\s+/g, "");
  }};
  try {{
    const typeInput = ctx.locator(".dt-type-selector input.el-input__inner, .dt-type-selector .el-input__inner").first();
    if (await typeInput.count().catch(() => 0)) {{
      for (let attempt = 0; attempt < 2; attempt++) {{
        await typeInput.click({{ timeout: 5000 }}).catch(() => null);
        await sleep(350);
        const clickedCtx = await clickNaturalOption(ctx).catch(() => false);
        const clickedPage = await clickNaturalOption(page).catch(() => false);
        if (clickedCtx || clickedPage) {{
          clickedNatural = true;
        }}
        await sleep(450);
        typeValue = await readTypeValue();
        if (typeValue.includes("月") || typeValue.includes("自然")) {{
          clickedNatural = true;
          break;
        }}
        // keyboard fallback: move from 日 to 自然月
        await typeInput.click({{ timeout: 5000 }}).catch(() => null);
        await typeInput.press("ArrowDown").catch(() => null);
        await typeInput.press("ArrowDown").catch(() => null);
        await typeInput.press("Enter").catch(() => null);
        await sleep(350);
        typeValue = await readTypeValue();
        if (typeValue.includes("月") || typeValue.includes("自然")) {{
          clickedNatural = true;
          break;
        }}
      }}
    }}
  }} catch {{}}
  await sleep(700);
  if (!clickedNatural) {{
    typeValue = await readTypeValue();
    return {{
      ok:false,
      code:"PERIOD_TYPE_NOT_MONTH",
      url: page.url(),
      frame: ctx === page ? null : ctx.url(),
      clickedNatural,
      typeValue,
      expectedYm: ym,
    }};
  }}

  try {{
    const monthInput = ctx.locator(".dt-selector input.el-input__inner, .dt-selector .el-input__inner").first();
    if (await monthInput.count().catch(() => 0)) {{
      await monthInput.click({{ clickCount: 3, timeout: 5000 }}).catch(() => null);
      await monthInput.fill(ym, {{ timeout: 5000 }}).catch(() => null);
      await monthInput.press("Enter").catch(() => null);
      await monthInput.evaluate((el, v) => {{
        el.value = v;
        el.dispatchEvent(new Event('input', {{ bubbles: true }}));
        el.dispatchEvent(new Event('change', {{ bubbles: true }}));
        el.dispatchEvent(new KeyboardEvent('keydown', {{ bubbles: true, key: 'Enter', code: 'Enter' }}));
        el.dispatchEvent(new KeyboardEvent('keyup', {{ bubbles: true, key: 'Enter', code: 'Enter' }}));
      }}, ym).catch(() => null);
      await sleep(900);
      monthValue = (await monthInput.inputValue().catch(() => "")) || "";
      clickedMonth = monthValue.includes(ym);
    }}
  }} catch {{}}

  if (!clickedMonth) {{
    // fallback: maybe this page switched to date-range picker
    try {{
      const startInput = ctx.locator('.dt-selector input.el-range-input, .dt-selector input[placeholder*="开始"], .dt-selector input[placeholder*="起始"]').first();
      const endInput = ctx.locator('.dt-selector input.el-range-input, .dt-selector input[placeholder*="结束"], .dt-selector input[placeholder*="截止"]').nth(1);
      const startCnt = await startInput.count().catch(() => 0);
      const endCnt = await endInput.count().catch(() => 0);
      if (startCnt && endCnt) {{
        await startInput.click({{ clickCount: 3, timeout: 5000 }}).catch(() => null);
        await startInput.fill(spec.start, {{ timeout: 5000 }}).catch(() => null);
        await startInput.press("Enter").catch(() => null);
        await endInput.click({{ clickCount: 3, timeout: 5000 }}).catch(() => null);
        await endInput.fill(spec.end, {{ timeout: 5000 }}).catch(() => null);
        await endInput.press("Enter").catch(() => null);
        await sleep(900);
        const sv = (await startInput.inputValue().catch(() => "")) || "";
        const ev = (await endInput.inputValue().catch(() => "")) || "";
        monthValue = `${{sv}}~${{ev}}`;
        clickedMonth = sv.includes(spec.start.slice(0,7)) && ev.includes(spec.end.slice(0,7));
      }}
    }} catch {{}}
  }}

  try {{
    const queryBtn = ctx.locator('button:has-text("\\u67e5\\u8be2"), .el-button:has-text("\\u67e5\\u8be2"), span:has-text("\\u67e5\\u8be2")').first();
    if (await queryBtn.count().catch(() => 0)) {{
      await queryBtn.click({{ timeout: 5000 }}).catch(() => null);
      clickedQuery = true;
      await sleep(2200);
    }}
  }} catch {{}}
  if (!clickedQuery) {{
    // JS fallback click
    clickedQuery = await ctx.evaluate(() => {{
      const visible = (el) => {{
        if (!el) return false;
        const s = getComputedStyle(el);
        const r = el.getBoundingClientRect();
        return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 1 && r.height > 1;
      }};
      const nodes = Array.from(document.querySelectorAll('button,span,a')).filter(visible);
      const target = nodes.find((n) => ((n.textContent || '').replace(/\\s+/g, '')).includes('查询'));
      if (!target) return false;
      target.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
      return true;
    }}).catch(() => false);
    if (clickedQuery) await sleep(1500);
  }}
  await sleep(1200);
  // close popup if present (best-effort)
  try {{
    await ctx.evaluate(() => {{
      const visible = (el) => {{
        if (!el) return false;
        const s = getComputedStyle(el);
        const r = el.getBoundingClientRect();
        return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 1 && r.height > 1;
      }};
      const close = Array.from(document.querySelectorAll('.el-dialog__close,.el-icon-close,[aria-label=\"Close\"]')).find(visible);
      if (close) {{
        close.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
      }}
    }});
  }} catch {{}}
  try {{
    await page.evaluate(() => {{
      const close = document.querySelector('.el-dialog__close,.el-icon-close,[aria-label="Close"]');
      if (close) close.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    }});
  }} catch {{}}
  // close open dropdown/picker panels
  try {{
    await ctx.evaluate(() => {{
      const x = Math.max(20, window.innerWidth - 40);
      const y = 240;
      const target = document.elementFromPoint(x, y) || document.body;
      target.dispatchEvent(new MouseEvent('click', {{ bubbles: true, clientX: x, clientY: y }}));
    }});
  }} catch {{}}
  try {{
    await page.keyboard.press('Escape');
    await page.keyboard.press('Escape');
  }} catch {{}}
  try {{
    await ctx.evaluate(() => {{
      const panels = document.querySelectorAll('.el-select-dropdown,.el-picker-panel,.el-date-picker');
      panels.forEach((el) => {{
        (el).style.display = 'none';
        (el).style.visibility = 'hidden';
      }});
    }});
  }} catch {{}}
  await sleep(500);

  // locate summary metric area by robust structural heuristics
  const shot = await ctx.evaluate(() => {{
    const visible = (el) => {{
      if (!el) return false;
      const s = getComputedStyle(el);
      const r = el.getBoundingClientRect();
      return s.display !== "none" && s.visibility !== "hidden" && r.width > 1 && r.height > 1;
    }};

    let summary = document.querySelector(".summary-container");
    if (!summary || !visible(summary)) {{
      const cards = Array.from(document.querySelectorAll(".el-card,.ui-card,.container,section,div"))
        .filter(visible)
        .filter((el) => {{
          const r = el.getBoundingClientRect();
          if (r.width < 900 || r.height < 140 || r.height > 520) return false;
          const txt = (el.textContent || '').replace(/\\s+/g, '');
          const keywordHits = ['会员总数','会员销售额','会员订单量','回访会员数','会员购买客户数','新客销售额'].filter((k) => txt.includes(k)).length;
          const numHits = (txt.match(/\\d+[\\.,]?\\d*[万亿%]?/g) || []).length;
          return keywordHits >= 2 || (keywordHits >= 1 && numHits >= 8);
        }});
      if (cards.length) {{
        cards.sort((a, b) => {{
          const ra = a.getBoundingClientRect();
          const rb = b.getBoundingClientRect();
          return (rb.width * rb.height) - (ra.width * ra.height);
        }});
        summary = cards[0];
      }}
    }}
    if (!summary) return {{ ok:false, reason:"SUMMARY_CONTAINER_NOT_FOUND", hits:[] }};

    const r = summary.getBoundingClientRect();
    const minX = Math.max(0, Math.floor(r.x - 10));
    const minY = Math.max(0, Math.floor(r.y - 6));
    const maxX = Math.min(window.innerWidth, Math.ceil(r.x + r.width + 10));
    const maxY = Math.min(window.innerHeight, Math.ceil(r.y + r.height + 8));
    return {{
      ok:true,
      clip:{{ x:minX, y:minY, width:Math.max(400, maxX-minX), height:Math.max(120, maxY-minY) }},
      hits:[{{x:r.x,y:r.y,w:r.width,h:r.height,text:"summary-container"}}]
    }};
  }});

  if (!shot.ok) {{
    return {{
      ok:false,
      code: shot.reason || 'CLIP_NOT_FOUND',
      url: page.url(),
      frame: ctx === page ? null : ctx.url(),
      clickedNatural,
      clickedMonth,
      typeValue,
      monthValue,
      clickedQuery,
      expectedYm: ym,
    }};
  }}

  let finalClip = shot.clip;
  if (ctx !== page) {{
    const frameEl = await ctx.frameElement();
    const fb = await frameEl.boundingBox();
    if (fb) {{
      finalClip = {{
        x: Math.max(0, Math.floor(fb.x + shot.clip.x)),
        y: Math.max(0, Math.floor(fb.y + shot.clip.y)),
        width: shot.clip.width,
        height: shot.clip.height
      }};
    }}
  }}

  await page.screenshot({{
    path: spec.output_path,
    clip: finalClip
  }});

  return {{
    ok:true,
    url: page.url(),
    frame: ctx === page ? null : ctx.url(),
    clickedNatural,
    clickedMonth,
    typeValue,
    monthValue,
    expectedYm: ym,
    output_path: spec.output_path,
    clickedQuery,
    clip: finalClip,
    hits: shot.hits
  }};
}}
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Capture VIS member overview metrics screenshot.")
    add_config_source_args(parser, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
    parser.add_argument("--session", default=SESSION_NAME)
    parser.add_argument("--auto-open", action="store_true", help="会话不存在时自动open浏览器（默认关闭）")
    parser.add_argument("--report-year", type=int, default=0)
    parser.add_argument("--report-month", default="")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    config_path = Path(args.config or r"D:\projects\skills\vip-report\config.yaml").resolve()
    cfg = load_runtime_config(args, default_config=config_path)
    workdir = Path(cfg["paths"]["workdir"]).resolve()
    year, month = resolve_period(cfg, args.report_year, args.report_month)
    ym = f"{year}-{month:02d}"
    start = f"{year}-{month:02d}-01"
    end = f"{year}-{month:02d}-{month_last_day(year, month):02d}"

    output_dir = workdir / "assets" / "cep-member-overview"
    output_dir.mkdir(parents=True, exist_ok=True)
    output_path = output_dir / f"member-overview-metrics-{year}{month:02d}.png"

    script_path = write_js(
        workdir,
        f"tmp-member-overview-capture-{year}{month:02d}-{time.time_ns()}.js",
        build_capture_js(
            {
                "url": TARGET_URL,
                "year": year,
                "month": month,
                "ym": ym,
                "start": start,
                "end": end,
                "output_path": str(output_path).replace("\\\\", "/"),
            }
        ),
    )
    try:
        output = run_code(args.session, script_path, cwd=workdir, timeout=420, auto_open=args.auto_open)
    finally:
        try:
            script_path.unlink()
        except FileNotFoundError:
            pass

    if "### Error" in output:
        raise RuntimeError(output.strip())

    json_line = ""
    for line in output.splitlines():
        s = line.strip()
        if s.startswith("{") and s.endswith("}"):
            json_line = s
            break
    if not json_line:
        raise RuntimeError(f"No JSON result from run-code: {output}")

    result = json.loads(json_line)
    if not result.get("ok", False):
        raise RuntimeError(json.dumps(result, ensure_ascii=False))

    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()
