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, Optional

from PIL import Image
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:
    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 month_range(year: int, month: int) -> tuple[str, str]:
    if month == 12:
        last_day = 31
    else:
        import calendar

        last_day = calendar.monthrange(year, month)[1]
    return f"{year}-{month:02d}-01", f"{year}-{month:02d}-{last_day:02d}"


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 curUrl = page.url() || '';
  if (!curUrl.includes('/frontend/memberOverview')) {{
    return {{ ok:false, code:'NOT_ON_MEMBER_OVERVIEW', url: curUrl }};
  }}
  await sleep(1200);
  let ctx = page;
  for (let i = 0; i < 40; 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 > 0) picked = frames[0];
    if (picked) {{
      ctx = picked;
      break;
    }}
    await sleep(300);
  }}
  const ctxReady = await ctx.evaluate(() => {{
    const txt = (document.body?.innerText || '').replace(/\\s+/g, '');
    return txt.includes('会员ARPU') || txt.includes('消费特征') || txt.includes('转化率');
  }}).catch(() => false);
  if (!ctxReady) {{
    return {{ ok:false, code:'PAGE_NOT_READY', frame: ctx===page?null:ctx.url(), url: page.url() }};
  }}

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

  // Locate the consumption feature card, then apply date/rate on that scope only.
  let card = ctx.locator('.el-card.ui-card.card-item').filter({{ hasText: '\\u6d88\\u8d39\\u7279\\u5f81' }}).first();
  if (!(await card.count().catch(() => 0))) {{
    card = ctx.locator('.el-card.ui-card.card-item,.card-item').filter({{ hasText: '\\u4eba\\u5747\\u6d88\\u8d39' }}).first();
  }}
  if (!(await card.count().catch(() => 0))) {{
    card = ctx.locator('.el-card.ui-card.card-item,.card-item').filter({{ hasText: '\\u8f6c\\u5316\\u7387' }}).first();
  }}
  if (!(await card.count().catch(() => 0))) {{
    return {{ ok:false, code:'ARPU_CARD_NOT_FOUND', frame: ctx===page?null:ctx.url(), url: page.url() }};
  }}

  let panel = card.locator('.el-col.el-col-15').first();
  if (!(await panel.count().catch(() => 0))) {{
    panel = card;
  }}
  await card.evaluate((el) => el && el.scrollIntoView && el.scrollIntoView({{ block: 'center', inline: 'nearest' }})).catch(() => null);
  await panel.evaluate((el) => el && el.scrollIntoView && el.scrollIntoView({{ block: 'center', inline: 'nearest' }})).catch(() => null);
  await sleep(900);

  let rangeSet = false;
  let startValue = '';
  let endValue = '';
  try {{
    const rangeInputs = card.locator('input.el-range-input');
    const cnt = await rangeInputs.count().catch(() => 0);
    if (cnt >= 2) {{
      const startInput = rangeInputs.nth(0);
      const endInput = rangeInputs.nth(1);
      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(1200);
      startValue = (await startInput.inputValue().catch(() => '')) || '';
      endValue = (await endInput.inputValue().catch(() => '')) || '';
      rangeSet = startValue.includes(spec.start) && endValue.includes(spec.end);
    }}
  }} catch {{}}

  let clickedRate = false;
  try {{
    const rate = card.locator('label,span').filter({{ hasText: '\\u8f6c\\u5316\\u7387' }}).first();
    if (await rate.count().catch(() => 0)) {{
      await rate.click({{ timeout: 5000 }}).catch(() => null);
      clickedRate = true;
    }}
  }} catch {{}}
  await sleep(1800);

  // Wait chart primitives ready, avoid blank captures.
  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 > 20 && r.height > 20;
      }};
      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 >= 500 && r.height >= 160;
      }});
    }}).catch(() => false);
    if (chartReady) break;
    await sleep(500);
  }}
  if (!chartReady) {{
    // Some environments render with delayed/non-canvas primitives.
    // Continue capture and let Python-side image validation decide retry/fail.
    await sleep(1200);
  }}

  let panelBox = await panel.boundingBox().catch(() => null);
  if (!panelBox) {{
    panelBox = await card.boundingBox().catch(() => null);
  }}
  await sleep(1200);

  // keep page visible; only close modal if present.
  try {{
    await ctx.evaluate(() => {{
      const close = document.querySelector('.el-dialog__close,.el-icon-close,[aria-label=\"Close\"]');
      if (close) close.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    }});
  }} catch {{}}
  try {{
    await page.keyboard.press('Escape');
  }} catch {{}}

  // Prefer element screenshots; if unstable timeout, fall back to page clip screenshot.
  let usedFallback = false;
  try {{
    await panel.screenshot({{ path: spec.output_path, timeout: 15000, animations: 'disabled' }});
  }} catch {{
    try {{
      await card.screenshot({{ path: spec.output_path, timeout: 15000, animations: 'disabled' }});
      usedFallback = true;
    }} catch {{
      usedFallback = true;
      const pickRect = async (loc) => {{
        return await loc.evaluate((el) => {{
          if (!el) return null;
          const r = el.getBoundingClientRect();
          if (!r || r.width < 20 || r.height < 20) return null;
          return {{
            x: Math.max(0, Math.floor(r.x)),
            y: Math.max(0, Math.floor(r.y)),
            width: Math.max(300, Math.ceil(r.width)),
            height: Math.max(180, Math.ceil(r.height)),
          }};
        }}).catch(() => null);
      }};
      let clip = await pickRect(panel);
      if (!clip) clip = await pickRect(card);
      if (!clip) {{
        clip = 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 > 40 && r.height > 40;
          }};
          const cards = Array.from(document.querySelectorAll('.el-card,.card-item,section,div'))
            .filter(visible)
            .filter((el) => {{
              const t = ((el.textContent || '').replace(/\s+/g, ''));
              return t.includes('消费特征') || (t.includes('人均消费') && t.includes('转化率'));
            }})
            .sort((a, b) => (b.getBoundingClientRect().width * b.getBoundingClientRect().height) - (a.getBoundingClientRect().width * a.getBoundingClientRect().height));
          const card = cards[0];
          if (!card) return null;
          let target = card.querySelector('.el-col.el-col-15');
          if (!target || !visible(target)) {{
            const chartNode = Array.from(card.querySelectorAll('canvas,svg')).filter(visible)
              .sort((a, b) => (b.getBoundingClientRect().width * b.getBoundingClientRect().height) - (a.getBoundingClientRect().width * a.getBoundingClientRect().height))[0];
            if (chartNode) {{
              target = chartNode.closest('.el-col,.chart-container,.chart-wrap,.chart') || chartNode.parentElement || card;
            }} else {{
              target = card;
            }}
          }}
          if (!visible(target)) return null;
          const r = target.getBoundingClientRect();
          return {{
            x: Math.max(0, Math.floor(r.x)),
            y: Math.max(0, Math.floor(r.y)),
            width: Math.max(560, Math.ceil(r.width)),
            height: Math.max(260, Math.ceil(r.height)),
          }};
        }}).catch(() => null);
      }}
      if (clip) {{
        if (ctx !== page) {{
          const frameEl = await ctx.frameElement().catch(() => null);
          const fb = frameEl ? await frameEl.boundingBox().catch(() => null) : null;
          if (fb) {{
            clip = {{
              x: Math.max(0, Math.floor(fb.x + clip.x)),
              y: Math.max(0, Math.floor(fb.y + clip.y)),
              width: Math.max(300, Math.ceil(clip.width)),
              height: Math.max(180, Math.ceil(clip.height)),
            }};
          }}
        }}
        await page.screenshot({{ path: spec.output_path, clip }});
      }} else {{
        // last-resort fallback: capture current viewport to avoid hard failure
        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: panelBox ? {{
      x: Math.max(0, Math.floor(panelBox.x)),
      y: Math.max(0, Math.floor(panelBox.y)),
      width: Math.max(560, Math.ceil(panelBox.width)),
      height: Math.max(260, Math.ceil(panelBox.height))
    }} : null,
    start: startValue,
    end: endValue,
    clickedRate: clickedRate,
    rangeSet,
    chartReady,
    usedFallback
  }};
}}
"""


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Capture member consumption conversion 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)
    start, end = month_range(year, month)
    out_dir = workdir / "assets" / "cep-member-overview"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"member-consumption-convert-{year}{month:02d}.png"

    last_error: Optional[Exception] = None
    result: dict[str, Any] = {}
    for attempt in range(1, 4):
        js_path = write_js(
            workdir,
            f"tmp-member-consume-capture-{year}{month:02d}-{time.time_ns()}.js",
            build_js(
                {
                    "url": TARGET_URL,
                    "start": start,
                    "end": end,
                    "output_path": str(out_path).replace("\\\\", "/"),
                }
            ),
        )
        try:
            out = run_code(args.session, js_path, cwd=workdir, timeout=420, auto_open=args.auto_open)
            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=760, min_h=240, min_bytes=8000)
            last_error = None
            break
        except Exception as e:
            last_error = e
            if attempt < 3:
                time.sleep(1.2)
        finally:
            try:
                js_path.unlink()
            except FileNotFoundError:
                pass

    if last_error is not None:
        raise last_error
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

