from __future__ import annotations

import argparse
import base64
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"
DEFAULT_DOWNLOAD_DIR = r"C:\vipdata"


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 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);

  function safeName(name) {{
    return String(name || '')
      .replace(/[\\\\/:*?"<>|]/g, '_')
      .replace(/\\s+/g, '_')
      .replace(/_+/g, '_');
  }}

  await page.goto(`${{spec.url}}?t=${{Date.now()}}`, {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
  await sleep(9000);

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

  // Set stats date through Element UI's picker state, then verify the visible inputs.
  const dateState = 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 ranges = Array.from(document.querySelectorAll('.el-date-editor--daterange')).filter(visible);
    const hasStatsLabel = (el) => {{
      let cur = el;
      for (let i = 0; cur && i < 7; i += 1, cur = cur.parentElement) {{
        const t = ((cur.textContent || '')).replace(/\s+/g, ' ').trim();
        if (t.includes('\u7edf\u8ba1\u65e5\u671f')) return true;
      }}
      return false;
    }};
    const range = ranges.find(hasStatsLabel) || ranges[0];
    if (!range) return {{ ok: false, code: 'DATE_RANGE_NOT_FOUND', startValue: '', endValue: '' }};

    const dp = range.__vue__;
    const d1 = new Date(`${{spec.start}}T00:00:00`);
    const d2 = new Date(`${{spec.end}}T00:00:00`);
    if (dp && dp.showPicker && dp.picker) {{
      try {{
        dp.showPicker();
        await sleep(180);
        if (dp.picker.handleRangePick) {{
          dp.picker.handleRangePick({{ minDate: d1, maxDate: d2 }}, true);
        }}
        if (dp.emitChange) {{
          dp.emitChange([d1, d2]);
        }}
        if (dp.hidePicker) {{
          dp.hidePicker();
        }}
      }} catch {{}}
    }}

    const ins = range.querySelectorAll('input.el-range-input');
    const setVal = (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 }}));
    }};
    setVal(ins[0], spec.start);
    setVal(ins[1], spec.end);
    await sleep(150);
    const startValue = ins[0] ? ins[0].value : '';
    const endValue = ins[1] ? ins[1].value : '';
    return {{ ok: startValue === spec.start && endValue === spec.end, startValue, endValue }};
  }}, spec).catch((err) => ({{ ok: false, code: 'DATE_SET_ERROR', error: String(err), startValue: '', endValue: '' }}));
  if (!dateState.ok) {{
    return {{ ok: false, code: dateState.code || 'DATE_RANGE_NOT_SET', dateState, expectedStart: spec.start, expectedEnd: spec.end, url: page.url() }};
  }}

  // query
  try {{
    const queryBtn = ctx.locator('button:has-text("查询"), span:has-text("查询")').first();
    if (await queryBtn.count().catch(() => 0)) {{
      await queryBtn.click({{ timeout: 3000 }}).catch(() => null);
    }}
  }} catch {{}}
  await sleep(2500);

  // click tab: 拒退商品分析
  const tabOk = 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 tabs = Array.from(document.querySelectorAll('div,span,a')).filter((el) => visible(el) && text(el) === '拒退商品分析');
    if (!tabs.length) return false;
    const t = tabs[0];
    t.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    return true;
  }});
  if (!tabOk) {{
    return {{ ok: false, code: 'GOODS_TAB_NOT_FOUND', url: page.url() }};
  }}
  await sleep(1500);

  // click 下载数据
  const clickDownload = 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 cards = Array.from(document.querySelectorAll('.g-card')).filter(visible);
    const detailCard = cards.find((card) => text(card).includes('拒退商品明细'))
      || cards.find((card) => text(card).includes('款号') && text(card).includes('下载数据'))
      || null;
    const root = detailCard || document;
    const candidates = Array.from(root.querySelectorAll('.ad-toolbar-box button, .ad-toolbar-box span, button'))
      .filter((el) => visible(el) && text(el) === '下载数据');
    const btn = candidates.find((el) => el.closest('.ad-toolbar-box')) || candidates[0];
    if (!btn) {{
      return {{
        ok: false,
        cardTexts: cards.map((card) => text(card).slice(0, 160)).slice(0, 8),
      }};
    }}
    const clickable = btn.closest('button') || btn;
    clickable.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }}));
    clickable.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }}));
    clickable.dispatchEvent(new MouseEvent('click', {{ bubbles: true }}));
    return {{
      ok: true,
      cardText: detailCard ? text(detailCard).slice(0, 240) : '',
      buttonText: text(clickable),
    }};
  }});
  if (!clickDownload.ok) {{
    return {{ ok: false, code: 'DOWNLOAD_DATA_BUTTON_NOT_FOUND', clickDownload, url: page.url() }};
  }}
  await sleep(1000);

  // click 不需要 in optional modal
  try {{
    const noNeed = ctx.locator('.el-message-box button:has-text("不需要"), .el-dialog button:has-text("不需要"), button:has-text("不需要")').first();
    await noNeed.click({{ timeout: 15000, force: true }}).catch(() => null);
    await sleep(1500);
  }} catch {{}}

  await page.goto('https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter', {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
  await sleep(1200);

  // go to download center
  try {{
    const listLink = page.locator('a:has-text("下载列表"), span:has-text("下载列表"), button:has-text("下载列表")').first();
    if (await listLink.count().catch(() => 0)) {{
      await listLink.click({{ timeout: 3000 }}).catch(() => null);
    }} else {{
      await page.goto('https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter', {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
    }}
  }} catch {{
    await page.goto('https://compass.vip.com/frontend/index.html#/dataCapturing/downloadCenter', {{ waitUntil: 'domcontentloaded', timeout: 120000 }});
  }}
  await sleep(3500);

  const rowsSeen = [];
  for (let i = 0; i < spec.max_retries; i++) {{
    const queryBtn = page.locator('button:has-text("查询"), span:has-text("查询")').first();
    if (await queryBtn.count().catch(() => 0)) {{
      await queryBtn.click({{ timeout: 2500 }}).catch(() => null);
    }}
    await sleep(2000);

    const rowCount = await page.locator('tbody tr').count().catch(() => 0);
    for (let idx = 0; idx < Math.min(rowCount, 10); idx++) {{
      const row = page.locator('tbody tr').nth(idx);
      const rowText = (await row.innerText().catch(() => '')).replace(/\\s+/g, ' ').trim();
      const fileSpan = row.locator('.status-name-span').first();
      let uiFileName = await fileSpan.getAttribute('title').catch(() => '');
      if (!uiFileName) {{
        uiFileName = (await fileSpan.innerText().catch(() => '')).trim();
      }}
      rowsSeen.push({{ row: idx + 1, uiFileName, rowText }});
      if (!uiFileName.includes('款号粒度') || !/执行成功|成功/.test(rowText || '')) {{
        continue;
      }}

      const taskId = (rowText.match(/^\\s*(\\d+)/) || [])[1] || '';
      const downloadBtn = row.locator('button:has-text("下载"), span:has-text("下载")').first();
      if (!(await downloadBtn.count().catch(() => 0))) {{
        continue;
      }}

      if (!taskId) {{
        continue;
      }}
      const filePayload = await page.evaluate(async (tid) => {{
        const body = new URLSearchParams();
        body.set('reqData', JSON.stringify({{ taskId: tid }}));
        const res = await fetch('/report/asyncdownload/getOutputFile', {{
          method: 'POST',
          headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},
          body: body.toString(),
          credentials: 'include',
        }});
        const ab = await res.arrayBuffer();
        const bytes = new Uint8Array(ab);
        let binary = '';
        const chunkSize = 0x8000;
        for (let offset = 0; offset < bytes.length; offset += chunkSize) {{
          binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
        }}
        return {{
          ok: res.ok,
          status: res.status,
          contentType: res.headers.get('content-type') || '',
          disposition: res.headers.get('content-disposition') || '',
          base64: btoa(binary),
        }};
      }}, taskId);
      if (!filePayload.ok) {{
        throw new Error(`getOutputFile failed status=${{filePayload.status}} taskId=${{taskId}}`);
      }}
      const finalName = safeName(`${{spec.prefix}}_${{uiFileName || taskId + '.xlsx'}}`);
      const targetPath = `${{spec.download_dir.replace(/\\\\/g, '/')}}/${{finalName}}`;
      return {{
        ok: true,
        url: page.url(),
        row: idx + 1,
        taskId,
        rowText,
        uiFileName,
        contentType: filePayload.contentType,
        disposition: filePayload.disposition,
        contentBase64: filePayload.base64,
        savedAs: targetPath
      }};
    }}
  }}

  return {{ ok: false, code: 'GOODS_STYLE_FILE_NOT_READY', rowsSeen: rowsSeen.slice(0, 40), url: page.url() }};
}}
"""


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Download Compass rejection goods analysis file via download center.")
    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("--download-dir", default=DEFAULT_DOWNLOAD_DIR)
    p.add_argument("--max-retries", type=int, default=15)
    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)
    prefix = f"{year}{month:02d}"

    download_dir = Path(args.download_dir).resolve()
    download_dir.mkdir(parents=True, exist_ok=True)

    js_path = write_js(
        workdir,
        f"tmp-compass-rejection-goods-download-{prefix}-{time.time_ns()}.js",
        build_js(
            {
                "url": TARGET_URL,
                "start": start,
                "end": end,
                "prefix": prefix,
                "download_dir": str(download_dir),
                "max_retries": int(args.max_retries),
            }
        ),
    )
    try:
        out = run_code(args.session, js_path, cwd=workdir, timeout=1200)
    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))
    content_b64 = result.pop("contentBase64", "")
    if content_b64:
        target = Path(result["savedAs"]).resolve()
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(base64.b64decode(content_b64))
    print(json.dumps(result, ensure_ascii=True))


if __name__ == "__main__":
    main()
