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

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]:
    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 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;
  let fallbackFrame = null;
  for (let i = 0; i < 25; i++) {{
    const frames = page.frames().filter((fr) => fr.url().includes('bct.vip.com') && fr.url().includes('/frontend/memberOverview'));
    if (!fallbackFrame && frames.length) {{
      fallbackFrame = frames[0];
    }}
    for (const f of frames) {{
      let frameVisible = false;
      try {{
        const fe = await f.frameElement();
        const fb = await fe.boundingBox();
        frameVisible = !!(fb && fb.width > 300 && fb.height > 200);
      }} catch {{}}
      const usable = frameVisible && await f.evaluate(() => !!document.body && document.body.scrollHeight > 300 && document.querySelectorAll('*').length > 20).catch(() => false);
      if (usable) {{
        ctx = f;
        break;
      }}
    }}
    if (ctx !== page) break;
    await sleep(400);
  }}
  if (ctx === page && fallbackFrame) {{
    ctx = fallbackFrame;
  }}
  if (ctx !== page) {{
    for (let i = 0; i < 20; i++) {{
      const ready = await ctx.evaluate(() => !!document.body && document.body.scrollHeight > 300).catch(() => false);
      if (ready) break;
      await sleep(400);
    }}
  }}

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

  // Scroll to "鍚勬笭閬撲細鍛樻暟瓒嬪娍" area
  await ctx.evaluate(() => window.scrollTo(0, 0));
  await sleep(300);
  await ctx.evaluate(() => window.scrollTo(0, Math.max(0, document.body.scrollHeight * 0.42)));
  await sleep(1200);

  let card = ctx.locator('.ui-card-item.ml-20').filter({{ hasText: '入会渠道' }}).first();
  if (!(await card.count().catch(() => 0))) {{
    card = ctx.locator('.ui-card-item.ml-20').filter({{ hasText: '各渠道会员数趋势' }}).first();
  }}
  if (!(await card.count().catch(() => 0))) {{
    return {{ ok:false, code:'CHANNEL_TREND_CARD_NOT_FOUND', url:page.url(), frame:ctx===page?null:ctx.url() }};
  }}
  const panel = card.locator('.user-container').first();
  if (!(await panel.count().catch(() => 0))) {{
    return {{ ok:false, code:'CHANNEL_TREND_PANEL_NOT_FOUND', url:page.url(), frame:ctx===page?null:ctx.url() }};
  }}

  // set date range in this panel to config month day-range
  let startValue = '';
  let endValue = '';
  let rangeSet = false;
  try {{
    const rangeInputs = panel.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(1800);
      startValue = (await startInput.inputValue().catch(() => '')) || '';
      endValue = (await endInput.inputValue().catch(() => '')) || '';
      rangeSet = startValue.includes(spec.start) && endValue.includes(spec.end);
      if (!rangeSet) {{
        await startInput.evaluate((el, v) => {{
          try {{ el.removeAttribute('readonly'); }} catch {{}}
          el.value = v;
          el.dispatchEvent(new Event('input', {{ bubbles: true }}));
          el.dispatchEvent(new Event('change', {{ bubbles: true }}));
        }}, spec.start).catch(() => null);
        await endInput.evaluate((el, v) => {{
          try {{ el.removeAttribute('readonly'); }} catch {{}}
          el.value = v;
          el.dispatchEvent(new Event('input', {{ bubbles: true }}));
          el.dispatchEvent(new Event('change', {{ bubbles: true }}));
        }}, spec.end).catch(() => null);
        await panel.evaluate(() => {{
          const target = document.body || document.documentElement;
          target.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
        }}).catch(() => null);
        await sleep(1200);
        startValue = (await startInput.inputValue().catch(() => '')) || startValue;
        endValue = (await endInput.inputValue().catch(() => '')) || endValue;
        rangeSet = startValue.includes(spec.start) && endValue.includes(spec.end);
      }}
    }}
  }} catch {{}}

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

  // Wait trend chart rendered.
  let chartReady = false;
  for (let i = 0; i < 14; 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 >= 600 && r.height >= 180;
      }});
    }}).catch(() => false);
    if (chartReady) break;
    await sleep(400);
  }}
  // even if chart is not fully ready, continue with fallback clipping.

  try {{
    await panel.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,
      startValue,
      endValue,
      chartReady,
      rangeSet
    }};
  }} catch {{}}

  const panelClip = 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 fromUserContainer = Array.from(document.querySelectorAll('.user-container')).filter(visible).map((el) => el.getBoundingClientRect())
      .filter((r) => r.width > 380 && r.height > 180 && r.y > 120);
    if (fromUserContainer.length) {{
      fromUserContainer.sort((a, b) => b.y - a.y);
      const r = fromUserContainer[0];
      if (r && r.width >= 10 && r.height >= 10) {{
        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))
        }};
      }}
    }}

    // Fallback: locate large chart canvas/SVG in lower page area, then expand to container region.
    const chartNodes = Array.from(document.querySelectorAll('canvas,svg')).filter(visible).map((el) => el.getBoundingClientRect())
      .filter((r) => r.width > 500 && r.height > 180 && r.y > 180);
    if (chartNodes.length) {{
      chartNodes.sort((a, b) => b.y - a.y);
      const cr = chartNodes[0];
      const x = Math.max(0, Math.floor(cr.x - 16));
      const y = Math.max(0, Math.floor(cr.y - 58));
      const width = Math.max(520, Math.ceil(cr.width + 32));
      const height = Math.max(240, Math.ceil(cr.height + 130));
      return {{ x, y, width, height }};
    }}

    const r = null;
    if (!r || r.width < 10 || r.height < 10) return null;
    return null;
  }}).catch(() => null);
  let finalClip = panelClip;
  if (ctx !== page) {{
    const frameEl = await ctx.frameElement();
    const fb = await frameEl.boundingBox();
    if (fb) {{
      if (finalClip) {{
        finalClip = {{
          x: Math.max(0, Math.floor(fb.x + finalClip.x)),
          y: Math.max(0, Math.floor(fb.y + finalClip.y)),
          width: finalClip.width,
          height: finalClip.height
        }};
      }} else {{
        // Hard fallback in iframe: use lower-middle region where channel trend chart is usually rendered.
        finalClip = {{
          x: Math.max(0, Math.floor(fb.x + 120)),
          y: Math.max(0, Math.floor(fb.y + 220)),
          width: Math.max(520, Math.floor(fb.width - 220)),
          height: 420
        }};
      }}
    }}
  }} else if (!finalClip) {{
    const vp = page.viewportSize() || {{ width: 1280, height: 720 }};
    finalClip = {{
      x: 120,
      y: 260,
      width: Math.max(520, vp.width - 220),
      height: Math.max(320, Math.min(440, vp.height - 280))
    }};
  }}

  if (!finalClip) {{
    const vp = page.viewportSize() || {{ width: 1280, height: 720 }};
    finalClip = {{
      x: 120,
      y: 260,
      width: Math.max(520, vp.width - 220),
      height: Math.max(320, Math.min(440, vp.height - 280))
    }};
  }}

  const vp = page.viewportSize() || {{ width: 1280, height: 720 }};
  const clampClip = (c) => {{
    if (!c) return null;
    const x = Math.max(0, Math.floor(c.x));
    const y = Math.max(0, Math.floor(c.y));
    if (x >= vp.width || y >= vp.height) return null;
    const width = Math.max(1, Math.min(Math.floor(c.width), vp.width - x));
    const height = Math.max(1, Math.min(Math.floor(c.height), vp.height - y));
    if (width < 20 || height < 20) return null;
    return {{ x, y, width, height }};
  }};
  finalClip = clampClip(finalClip);
  if (finalClip) {{
    try {{
      await page.screenshot({{ path: spec.output_path, clip: finalClip }});
    }} catch {{
      await panel.screenshot({{ path: spec.output_path }}).catch(async () => {{
        await page.screenshot({{ path: spec.output_path }});
      }});
      finalClip = null;
    }}
  }} else {{
    await panel.screenshot({{ path: spec.output_path }}).catch(async () => {{
      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: finalClip,
    startValue,
    endValue,
    chartReady,
    rangeSet
  }};
}}
"""


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Capture channel member trend 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-channel-trend-{year}{month:02d}.png"

    js_path = write_js(
        workdir,
        f"tmp-member-channel-trend-{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)
    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=560, min_h=250, min_bytes=10000)
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()


