from __future__ import annotations

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

import yaml


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://compass.vip.com/frontend/index.html#/rejectionAnalysis/index"


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"
    npm_cache.mkdir(parents=True, exist_ok=True)
    env["NPM_CONFIG_CACHE"] = str(npm_cache)
    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) -> str:
    return run_cmd(PLAYWRIGHT_CMD + args, cwd=cwd, timeout=timeout).stdout


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) -> str:
    return run_playwright(["--session", session, "run-code", "--filename", str(script_path)], cwd=cwd, timeout=timeout)


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 previous_month_range(year: int, month: int) -> tuple[str, str]:
    if month == 1:
        prev_year, prev_month = year - 1, 12
    else:
        prev_year, prev_month = year, month - 1
    return month_range(prev_year, prev_month)


def resolve_period(config_path: Path, report_year_arg: int, report_month_arg: str) -> tuple[int, int]:
    cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    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 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);

  await page.setViewportSize({{ width: spec.viewport_width, height: spec.viewport_height }}).catch(() => null);
  await page.goto(`${{spec.url}}?t=${{Date.now()}}`, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
  await sleep(9000);

  async function isTarget(ctx) {{
    try {{
      return await ctx.evaluate(() => {{
        const t = (document.body && document.body.innerText) || '';
        return t.includes('拒退趋势') && t.includes('核心指标');
      }});
    }} catch {{
      return false;
    }}
  }}

  let ctx = page;
  if (!(await isTarget(page))) {{
    for (let i = 0; i < 30; i++) {{
      let found = null;
      for (const f of page.frames()) {{
        if (f === page.mainFrame()) continue;
        if (await isTarget(f)) {{
          found = f;
          break;
        }}
      }}
      if (found) {{
        ctx = found;
        break;
      }}
      await sleep(350);
    }}
  }}

  // close reminder popup
  try {{
    const knowBtn = ctx.locator('button:has-text("已知悉"), span:has-text("已知悉")').first();
    if (await knowBtn.count().catch(() => 0)) {{
      await knowBtn.click({{ timeout: 3000 }}).catch(() => null);
      await sleep(600);
    }}
  }} catch {{}}

  let brandSelected = false;
  let pieceViewSelected = false;
  let startValue = '';
  let endValue = '';

  // brand = CHARLES&KEITH (account may be scoped so brand selector can stay as 全部品牌)
  try {{
    const brandInput = ctx.locator('input.el-input__inner[placeholder="请选择品牌"], .el-form-item:has-text("品牌") input.el-input__inner').first();
    if (await brandInput.count().catch(() => 0)) {{
      const selectWrap = brandInput.locator('xpath=ancestor::*[contains(@class,"el-select")]').first();
      if (await selectWrap.count().catch(() => 0)) {{
        await selectWrap.click({{ timeout: 4000 }}).catch(() => null);
      }} else {{
        await brandInput.click({{ timeout: 4000 }}).catch(() => null);
      }}
      await sleep(300);
      const opt = ctx.locator('.el-select-dropdown__item:has-text("CHARLES&KEITH"), .el-select-dropdown__item:has-text("CHARLES & KEITH"), .el-select-dropdown__item:has-text("Charles & Keith")').first();
      if (await opt.count().catch(() => 0)) {{
        await opt.click({{ timeout: 4000 }}).catch(() => null);
      }}
      const cur = await brandInput.inputValue().catch(() => '');
      brandSelected = cur.toUpperCase().includes('CHARLES') || cur.includes('全部品牌');
    }}
  }} catch {{}}

  // set 统计日期 range by driving the outer Compass date-selector component first.
  let compassDateState = null;
  try {{
    compassDateState = await ctx.evaluate(async (spec) => {{
      const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
      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 selectors = Array.from(document.querySelectorAll('.gd-date-selector.date-selector')).filter((el) => visible(el) && !el.classList.contains('compare'));
      const selector = selectors[0];
      if (!selector || !selector.__vue__) return {{ ok: false, code: 'COMPASS_DATE_SELECTOR_NOT_FOUND' }};
      const vm = selector.__vue__;
      let parent = vm.$parent;
      const start = new Date(`${{spec.start}}T00:00:00`);
      const end = new Date(`${{spec.end}}T00:00:00`);
      try {{
        vm.curType = vm.TYPE_CUSTOM || 'custom';
        vm.start = spec.start;
        vm.end = spec.end;
        vm.curDate = [start, end];
        if (vm.dateChangeHandler) vm.dateChangeHandler([start, end]);
        if (vm.syncValue) vm.syncValue([start, end]);
        if (vm.syncValue2) vm.syncValue2([start, end]);
        if (vm.$emit) vm.$emit('input', [start, end]);
        while (parent && !Object.prototype.hasOwnProperty.call(parent, 'mainDate')) {{
          parent = parent.$parent;
        }}
        if (parent) {{
          const mainDate = {{ dateType: 'D', dtType: 0, start: spec.start, end: spec.end, type: 'custom', other: '' }};
          const compareDate = {{ dateType: 'D', dtType: 0, start: spec.compare_start, end: spec.compare_end, type: 'custom', other: '' }};
          if (parent.$set) {{
            parent.$set(parent, 'mainDate', mainDate);
            parent.$set(parent, 'compareDate', compareDate);
          }} else {{
            parent.mainDate = mainDate;
            parent.compareDate = compareDate;
          }}
          if (parent.changeDateHandler) parent.changeDateHandler(mainDate);
          if (parent.changeDateHandlerC) parent.changeDateHandlerC(compareDate);
          if (parent.handlerConditionChange) parent.handlerConditionChange();
          if (parent.getConfigs) parent.getConfigs();
        }}
      }} catch (err) {{
        return {{ ok: false, code: 'COMPASS_DATE_SET_ERROR', error: String(err) }};
      }}
      await sleep(4500);
      const inputs = selector.querySelectorAll('input.el-range-input');
      return {{
        ok: true,
        startValue: inputs[0] ? inputs[0].value : '',
        endValue: inputs[1] ? inputs[1].value : '',
        curType: vm.curType,
        start: vm.start,
        end: vm.end,
        parentMainDate: parent && parent.mainDate,
        parentCompareDate: parent && parent.compareDate
      }};
    }}, spec);
    await sleep(1200);
  }} catch {{}}

  // set 统计日期 range by driving internal ElDatePicker range pick API as fallback.
  try {{
    const statsRange = ctx.locator('.el-form-item:has-text("统计日期") .el-date-editor--daterange, .search-item:has-text("统计日期") .el-date-editor--daterange, .el-date-editor--daterange').first();
    if (await statsRange.count().catch(() => 0)) {{
      await ctx.evaluate(async (spec) => {{
        const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
        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 range = Array.from(document.querySelectorAll('.el-date-editor--daterange')).find(visible);
        if (!range) return;
        const dp = range.__vue__;
        if (dp && dp.showPicker && dp.picker) {{
          const d1 = new Date(`${{spec.start}}T00:00:00`);
          const d2 = new Date(`${{spec.end}}T00:00:00`);
          try {{
            dp.showPicker();
            await sleep(180);
            dp.picker.handleRangePick && dp.picker.handleRangePick({{ minDate: d1, maxDate: d2 }}, true);
            dp.emitChange && dp.emitChange([d1, d2]);
            dp.hidePicker && dp.hidePicker();
            await sleep(80);
          }} catch {{}}
        }}
        // fallback for readonly inputs
        const ins = range.querySelectorAll('input.el-range-input');
        const setValue = (el, v) => {{
          if (!el) return;
          el.value = v;
          el.dispatchEvent(new Event('input', {{ bubbles: true }}));
          el.dispatchEvent(new Event('change', {{ bubbles: true }}));
          el.dispatchEvent(new Event('blur', {{ bubbles: true }}));
        }};
        setValue(ins[0], spec.start);
        setValue(ins[1], spec.end);
      }}, spec).catch(() => null);
      await sleep(350);

      const rangeInputs = statsRange.locator('input.el-range-input');
      if (await rangeInputs.count().catch(() => 0) >= 2) {{
        startValue = await rangeInputs.nth(0).inputValue().catch(() => '');
        endValue = await rangeInputs.nth(1).inputValue().catch(() => '');
      }}
    }}
  }} catch {{}}

  // switch to 按件数查看
  try {{
    const numberLabel = ctx.locator('label.el-radio:has-text("按件数查看"), label:has-text("按件数查看")').first();
    if (await numberLabel.count().catch(() => 0)) {{
      await numberLabel.click({{ timeout: 3000 }}).catch(() => null);
      await sleep(300);
      const cls = await numberLabel.getAttribute('class').catch(() => '');
      pieceViewSelected = String(cls || '').includes('is-checked');
    }}
  }} catch {{}}

  // query button near the top stats date control
  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 text = (el) => ((el && el.textContent) || '').replace(/\\s+/g, ' ').trim();
      const editor = Array.from(document.querySelectorAll('.el-date-editor--daterange')).find(visible);
      const er = editor ? editor.getBoundingClientRect() : null;
      const buttons = Array.from(document.querySelectorAll('button,span,div'))
        .filter((el) => visible(el) && text(el) === '查询');
      if (!buttons.length) return;
      let best = buttons[0];
      if (er) {{
        const sorted = buttons
          .map((b) => {{
            const r = b.getBoundingClientRect();
            return {{ b, d: Math.abs((r.y + r.height / 2) - (er.y + er.height / 2)) + Math.abs(r.x - er.x) * 0.15 }};
          }})
          .sort((a, b) => a.d - b.d);
        best = sorted[0].b;
      }}
      best.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }}));
      best.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }}));
      best.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    }});
  }} catch {{}}

  await sleep(3000);

  // Reposition chart upward in viewport so x-axis labels/legend are not cut off.
  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 text = (el) => ((el && el.textContent) || '').replace(/\\s+/g, ' ').trim();
      const nodes = Array.from(document.querySelectorAll('div,section,article')).filter(visible);
      const trend = nodes.find((el) => text(el) === '拒退趋势' || text(el).startsWith('拒退趋势'));
      if (!trend) return;
      const r = trend.getBoundingClientRect();
      const targetBottom = window.innerHeight - 180;
      const need = r.bottom - targetBottom;
      if (need > 0) {{
        window.scrollBy(0, need + 30);
      }}
    }});
  }} catch {{}}
  await sleep(500);

  // hide dropdown/picker panels
  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 {{}}

  const shot = await ctx.evaluate((spec) => {{
    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 text = (el) => ((el && el.textContent) || '').replace(/\s+/g, ' ').trim();

    const nodes = Array.from(document.querySelectorAll('div,span,h1,h2,h3,h4,section,article')).filter(visible);
    const core = nodes.find((el) => text(el) === '核心指标' || text(el).startsWith('核心指标'));
    const trend = nodes.find((el) => text(el) === '拒退趋势' || text(el).startsWith('拒退趋势'));
    if (!core || !trend) return {{ ok: false, code: 'TITLE_NOT_FOUND' }};

    const rc = core.getBoundingClientRect();
    const rt = trend.getBoundingClientRect();

    const areaLeft = Math.max(0, Math.floor(Math.min(rc.x, rt.x) - 14));
    const areaTop = Math.max(0, Math.floor(Math.min(rc.y, rt.y) - 10));
    const areaRight = Math.min(window.innerWidth, Math.ceil(Math.max(rc.x + rc.width, rt.x + rt.width) + spec.clip_extra_right));

    // Fixed-height capture window to avoid including the next KPI block.
    const areaBottom = Math.min(window.innerHeight, Math.ceil(areaTop + spec.clip_height));

    const width = Math.max(980, areaRight - areaLeft);
    const height = Math.max(360, areaBottom - areaTop);
    return {{ ok: true, clip: {{ x: areaLeft, y: areaTop, width, height }} }};
  }}, spec);

  if (!shot.ok) {{
    return {{
      ok: false,
      code: shot.code || 'CLIP_NOT_FOUND',
      url: page.url(),
      frame: ctx === page ? null : ctx.url(),
      brandSelected,
      pieceViewSelected,
      startValue,
      endValue
    }};
  }}

  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(),
    output_path: spec.output_path,
    clip: finalClip,
    compassDateState,
    brandSelected,
    pieceViewSelected,
    startValue,
    endValue
  }};
}}
"""


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Capture Compass rejection-analysis core panel screenshot.")
    p.add_argument("--config", default=r"D:\projects\skills\vip-report\config.yaml")
    p.add_argument("--session", default=SESSION_NAME)
    p.add_argument("--report-year", type=int, default=0)
    p.add_argument("--report-month", default="")
    p.add_argument("--viewport-width", type=int, default=2200)
    p.add_argument("--viewport-height", type=int, default=1500)
    p.add_argument("--clip-extra-right", type=int, default=1500)
    p.add_argument("--clip-height", type=int, default=620)
    return p.parse_args()


def main() -> None:
    args = parse_args()
    config_path = Path(args.config).resolve()
    cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    workdir = Path(cfg["paths"]["workdir"]).resolve()

    year, month = resolve_period(config_path, args.report_year, args.report_month)
    start, end = month_range(year, month)
    compare_start, compare_end = previous_month_range(year, month)

    out_dir = workdir / "assets" / "cep-rejection-analysis"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"rejection-analysis-core-{year}{month:02d}.png"

    js_path = write_js(
        workdir,
        f"tmp-rejection-core-{year}{month:02d}-{time.time_ns()}.js",
        build_js(
            {
                "url": TARGET_URL,
                "start": start,
                "end": end,
                "compare_start": compare_start,
                "compare_end": compare_end,
                "output_path": str(out_path).replace('\\', '/'),
                "viewport_width": args.viewport_width,
                "viewport_height": args.viewport_height,
                "clip_extra_right": args.clip_extra_right,
                "clip_height": args.clip_height,
            }
        ),
    )
    try:
      out = run_code(args.session, js_path, cwd=workdir, timeout=420)
    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))
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()
