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))
for dep_dir in [SCRIPT_DIR / ".pydeps", SCRIPT_DIR.parent / ".pydeps", Path.cwd() / ".pydeps"]:
    if dep_dir.exists() and str(dep_dir) not in sys.path:
        sys.path.insert(0, str(dep_dir))

from PIL import Image
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:
    try:
        return run_cmd(PLAYWRIGHT_CMD + args, cwd=cwd, timeout=timeout).stdout
    except subprocess.CalledProcessError as e:
        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)
            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)
            return retry.stdout
        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:
    p = workdir / name
    p.write_text(content, encoding="utf-8")
    return p


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 validate_image(path: Path, *, min_w: int, min_h: int, min_bytes: int) -> None:
    if not path.exists():
        raise RuntimeError(f"capture output missing: {path}")
    if path.stat().st_size < min_bytes:
        raise RuntimeError(f"capture output too small: {path} size={path.stat().st_size}")
    with Image.open(path) as img:
        w, h = img.size
    if w < min_w or h < min_h:
        raise RuntimeError(f"capture output dimension too small: {path} size={w}x{h}")


def build_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 closeBlocking = async (scope) => {{
    try {{
      await scope.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 > 2 && r.height > 2;
        }};
        const txt = (el) => ((el?.textContent || '').replace(/\\s+/g, ''));
        const click = (el) => el.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
        const closeSelectors = '.el-dialog__close,.el-icon-close,[aria-label="Close"],[aria-label*="关闭"],[title*="关闭"],[class*="close"],[class*="Close"]';
        const hosts = Array.from(document.querySelectorAll('div,section,aside')).filter((el) => {{
          if (!visible(el)) return false;
          const s = getComputedStyle(el);
          const r = el.getBoundingClientRect();
          const z = Number(s.zIndex || 0);
          const fixedLike = s.position === 'fixed' || s.position === 'sticky' || s.position === 'absolute';
          const adLike = /广告|公告|活动|推荐|指引|帮助|下载中心/.test(txt(el));
          return (fixedLike && z >= 1000 && r.width > 120 && r.height > 60) || adLike;
        }});
        hosts.forEach((h) => {{
          const close = h.querySelector(closeSelectors);
          if (close && visible(close)) click(close);
          const know = Array.from(h.querySelectorAll('button,span,a,div')).find((n) => /已知悉|我知道了|知道了|关闭/.test(txt(n)) && visible(n));
          if (know) click(know);
        }});
        const topClose = Array.from(document.querySelectorAll(closeSelectors)).find(visible);
        if (topClose) click(topClose);
      }});
    }} catch {{}}
  }};
  const currentUrl = page.url() || '';
  if (!currentUrl.includes('/frontend/memberOverview')) {{
    return {{ ok:false, code:'NOT_ON_MEMBER_OVERVIEW', url: currentUrl }};
  }}
  await sleep(1500);

  let ctx = page;
  for (let i = 0; i < 25; i++) {{
    const frames = page.frames().filter((fr) => fr.url().includes('bct.vip.com') && fr.url().includes('/frontend/memberOverview'));
    let picked = null;
    for (const f of frames) {{
      try {{
        const fe = await f.frameElement();
        const fb = await fe.boundingBox();
        if (fb && fb.width > 300 && fb.height > 200) {{
          picked = f;
          break;
        }}
      }} catch {{}}
    }}
    if (!picked && frames.length) picked = frames[0];
    if (picked) {{
      ctx = picked;
      break;
    }}
    await sleep(400);
  }}

  // close modal/ad overlays
  await closeBlocking(ctx);
  await closeBlocking(page);
  try {{ await page.keyboard.press('Escape'); }} catch {{}}
  await sleep(300);

  await ctx.evaluate(() => window.scrollTo(0, Math.max(0, document.body.scrollHeight * 0.55)));
  await sleep(1200);

  // right panel: 订单分布
  const panel = ctx.locator('.el-col.el-col-9').filter({{ hasText: '\u8ba2\u5355\u5206\u5e03' }}).first();
  if (!(await panel.count().catch(() => 0))) {{
    return {{ ok: false, code: 'ORDER_PANEL_NOT_FOUND', url: page.url(), frame: ctx===page?null:ctx.url() }};
  }}
  await panel.evaluate((el) => el && el.scrollIntoView && el.scrollIntoView({{ block: 'center', inline: 'nearest' }})).catch(() => null);
  await sleep(700);

  // set "自然月"
  let typeValue = '';
  try {{
    const typeInput = panel.locator('.dt-type-selector input.el-input__inner').first();
    if (await typeInput.count().catch(() => 0)) {{
      typeValue = (await typeInput.inputValue().catch(() => '')) || '';
      if (!typeValue.includes('\u81ea\u7136\u6708')) {{
        await typeInput.click({{ timeout: 5000 }}).catch(() => null);
        await ctx.locator('li.el-select-dropdown__item:has-text("\u81ea\u7136\u6708")').first().click({{ timeout: 5000 }}).catch(() => null);
        await sleep(500);
      }}
      typeValue = (await typeInput.inputValue().catch(() => '')) || typeValue;
    }}
  }} catch {{}}

  // set month: YYYY-MM
  let monthValue = '';
  try {{
    const monthInput = panel.locator('.dt-selector input.el-input__inner').first();
    if (await monthInput.count().catch(() => 0)) {{
      await monthInput.click({{ clickCount: 3, timeout: 5000 }}).catch(() => null);
      await monthInput.fill(spec.ym, {{ timeout: 5000 }}).catch(() => null);
      await monthInput.press('Enter').catch(() => null);
      await sleep(1200);
      monthValue = (await monthInput.inputValue().catch(() => '')) || '';
    }}
  }} catch {{}}

  await closeBlocking(ctx);
  await closeBlocking(page);
  try {{ await page.keyboard.press('Escape'); }} catch {{}}
  await sleep(300);

  // Wait pie/chart rendered.
  let chartReady = false;
  for (let i = 0; i < 12; i++) {{
    chartReady = await panel.evaluate((el) => {{
      if (!el) return false;
      const visible = (n) => {{
        if (!n) return false;
        const s = getComputedStyle(n);
        const r = n.getBoundingClientRect();
        return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 10 && r.height > 10;
      }};
      const nodes = Array.from(el.querySelectorAll('canvas,svg')).filter(visible);
      if (!nodes.length) return false;
      return nodes.some((n) => {{
        const r = n.getBoundingClientRect();
        return r.width >= 160 && r.height >= 160;
      }});
    }}).catch(() => false);
    if (chartReady) break;
    await sleep(400);
  }}
  // keep going even if not ready; we'll fallback screenshot if needed.

  const chartTarget = panel.locator('.user-container').first();
  const target = (await chartTarget.count().catch(() => 0)) ? chartTarget : panel;
  try {{
    await target.screenshot({{ path: spec.output_path, timeout: 15000, animations: 'disabled' }});
    return {{
      ok: true,
      url: page.url(),
      frame: ctx===page?null:ctx.url(),
      output_path: spec.output_path,
      clip: null,
      chartReady,
      typeValue,
      monthValue,
      usedFallback: false
    }};
  }} catch {{}}
  let box = await target.boundingBox().catch(() => null);
  if (!box || box.width < 220 || box.height < 160) {{
    box = await panel.evaluate((el) => {{
      if (!el) return null;
      const r = el.getBoundingClientRect();
      if (!r || r.width < 220 || r.height < 160) return null;
      return {{ x: r.x, y: r.y, width: r.width, height: r.height }};
    }}).catch(() => null);
  }}
  let usedFallback = false;
  if (!box || box.width < 220 || box.height < 160) {{
    usedFallback = true;
    await page.screenshot({{ path: spec.output_path }});
    return {{
      ok: true,
      url: page.url(),
      frame: ctx===page?null:ctx.url(),
      output_path: spec.output_path,
      clip: null,
      chartReady,
      typeValue,
      monthValue,
      usedFallback
    }};
  }}
  let clip = {{
    x: Math.max(0, Math.floor(box.x)),
    y: Math.max(0, Math.floor(box.y)),
    width: Math.max(260, Math.ceil(box.width)),
    height: Math.max(180, Math.ceil(box.height))
  }};
  if (ctx !== page) {{
    const frameEl = await ctx.frameElement();
    const fb = await frameEl.boundingBox();
    if (fb) {{
      clip = {{
        x: Math.max(0, Math.floor(fb.x + clip.x)),
        y: Math.max(0, Math.floor(fb.y + clip.y)),
        width: clip.width,
        height: clip.height
      }};
    }}
  }}
  await page.screenshot({{ path: spec.output_path, clip }});
  return {{
    ok: true,
    url: page.url(),
    frame: ctx===page?null:ctx.url(),
    output_path: spec.output_path,
    clip,
    chartReady,
    typeValue,
    monthValue,
    usedFallback
  }};
}}
"""


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Capture member order distribution chart.")
    add_config_source_args(p, default_config=Path(r"D:\projects\skills\vip-report\config.yaml"))
    p.add_argument("--session", default=SESSION_NAME)
    p.add_argument("--auto-open", action="store_true", help="会话不存在时自动open浏览器（默认关闭）")
    p.add_argument("--report-year", type=int, default=0)
    p.add_argument("--report-month", default="")
    return p.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}"

    out_dir = workdir / "assets" / "cep-member-overview"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"member-order-distribution-{year}{month:02d}.png"

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

    if "### Error" in out:
        raise RuntimeError(out.strip())
    json_line = ""
    for line in out.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: {out}")
    result = json.loads(json_line)
    if not result.get("ok", False):
        raise RuntimeError(json.dumps(result, ensure_ascii=False))
    validate_image(out_path, min_w=240, min_h=170, min_bytes=8000)

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


if __name__ == "__main__":
    main()

