#!/usr/bin/env python3
"""Archive indexed WeCom online documents through the official WeCom CLI.

The script is intentionally read-only. It imports documents already registered in
work_company_knowledge/online-documents/online-document-index.csv and writes raw
API JSON plus CSV/Markdown derivatives into each archive directory.
"""

from __future__ import annotations

import argparse
import csv
import html
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any


DEFAULT_CLI_VERSION = "0.1.8"
DEFAULT_INDEX = Path("work_company_knowledge/online-documents/online-document-index.csv")
DEFAULT_CACHE_ROOT = Path.home() / ".cache" / "zhctprompt" / "wecom-cli"


@dataclass
class ImportResult:
    archive_id: str
    title: str
    source_type: str
    archive_dir: Path
    status: str
    rows: int = 0
    sheets: int = 0
    message: str = ""


def slugify(value: str) -> str:
    value = re.sub(r"\s+", "-", value.strip())
    value = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff._-]+", "-", value)
    return value.strip("-") or "sheet"


def write_json(path: Path, data: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def read_index(path: Path) -> list[dict[str, str]]:
    with path.open(newline="", encoding="utf-8-sig") as handle:
        return list(csv.DictReader(handle))


def platform_package(version: str) -> str:
    system = platform.system().lower()
    machine = platform.machine().lower()
    if system == "darwin":
        os_name = "darwin"
    elif system == "linux":
        os_name = "linux"
    elif system == "windows":
        os_name = "win32"
    else:
        raise RuntimeError(f"unsupported platform: {system}-{machine}")

    arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine
    if os_name == "win32" and arch != "x64":
        raise RuntimeError(f"unsupported platform: {system}-{machine}")
    return f"@wecom/cli-{os_name}-{arch}@{version}"


def resolve_wecom_cli(auto_install: bool, version: str, cache_root: Path) -> Path:
    env_bin = os.environ.get("WECOM_CLI_BIN")
    if env_bin and os.access(env_bin, os.X_OK):
        return Path(env_bin)

    path_bin = shutil.which("wecom-cli")
    if path_bin:
        return Path(path_bin)

    cached_bin = cache_root / version / "node_modules" / ".bin" / "wecom-cli"
    if cached_bin.exists() and os.access(cached_bin, os.X_OK):
        return cached_bin

    if not auto_install:
        raise RuntimeError("wecom-cli not found; rerun with --auto-install-cli or set WECOM_CLI_BIN")

    npm = shutil.which("npm")
    if not npm:
        raise RuntimeError("npm not found; cannot install @wecom/cli")

    prefix = cache_root / version
    prefix.mkdir(parents=True, exist_ok=True)
    packages = [f"@wecom/cli@{version}", platform_package(version)]
    subprocess.run([npm, "install", "--prefix", str(prefix), *packages], check=True)
    if not cached_bin.exists():
        raise RuntimeError(f"installed @wecom/cli but binary was not found: {cached_bin}")
    return cached_bin


def check_cli_ready(cli: Path) -> tuple[bool, str]:
    config_dir = Path(os.environ.get("WECOM_CLI_CONFIG_DIR", Path.home() / ".config" / "wecom"))
    bot = config_dir / "bot.enc"
    mcp = config_dir / "mcp_config.enc"
    if not bot.exists() or not mcp.exists():
        return (
            False,
            f"missing credential cache: expected {bot} and {mcp}; run `{cli} init` once",
        )
    return True, f"credential cache found: {config_dir}"


def run_cli(cli: Path, method: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]:
    command = [str(cli), "doc", method, json.dumps(payload, ensure_ascii=False)]
    completed = subprocess.run(command, text=True, capture_output=True, timeout=timeout)
    if completed.returncode != 0:
        raise RuntimeError((completed.stderr or completed.stdout or "").strip())
    output = completed.stdout.strip()
    try:
        return json.loads(output)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"non-json output from {method}: {output[:500]}") from exc


def run_first_success(
    cli: Path,
    methods: list[str],
    payload: dict[str, Any],
    timeout: int,
) -> tuple[str, dict[str, Any]]:
    errors: list[str] = []
    for method in methods:
        try:
            return method, run_cli(cli, method, payload, timeout)
        except Exception as exc:  # noqa: BLE001 - report all candidate failures.
            errors.append(f"{method}: {exc}")
    raise RuntimeError("; ".join(errors))


def find_dicts_with_key(data: Any, key: str) -> list[dict[str, Any]]:
    found: list[dict[str, Any]] = []
    if isinstance(data, dict):
        if key in data:
            found.append(data)
        for value in data.values():
            found.extend(find_dicts_with_key(value, key))
    elif isinstance(data, list):
        for item in data:
            found.extend(find_dicts_with_key(item, key))
    return found


def sheet_title(item: dict[str, Any], fallback: str) -> str:
    return str(item.get("title") or item.get("sheet_title") or item.get("name") or item.get("sheet_name") or fallback)


def int_value(item: dict[str, Any], *keys: str, default: int = 1) -> int:
    for key in keys:
        value = item.get(key)
        if isinstance(value, int):
            return value
        if isinstance(value, str) and value.isdigit():
            return int(value)
    return default


def col_name(index: int) -> str:
    result = ""
    while index:
        index, remainder = divmod(index - 1, 26)
        result = chr(65 + remainder) + result
    return result or "A"


def normalize_cell(value: Any) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return value.replace("\r\n", "\n").replace("\r", "\n").strip()
    if isinstance(value, (int, float, bool)):
        return str(value)
    if isinstance(value, list):
        return "; ".join(part for part in (normalize_cell(item) for item in value) if part)
    if isinstance(value, dict):
        for key in ("text", "value", "name", "url", "link"):
            if key in value and value[key] not in (None, ""):
                return normalize_cell(value[key])
        return json.dumps(value, ensure_ascii=False, sort_keys=True)
    return str(value)


def write_rows(output_dir: Path, stem: str, title: str, rows: list[list[str]]) -> tuple[Path, Path, int]:
    if not rows:
        rows = [[""]]
    width = max(len(row) for row in rows)
    normalized = [row + [""] * (width - len(row)) for row in rows]
    headers = normalized[0]
    data_rows = normalized[1:]

    csv_path = output_dir / "sheets" / f"{stem}.csv"
    md_path = output_dir / "sheets" / f"{stem}.md"
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    with csv_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.writer(handle)
        writer.writerow(headers)
        writer.writerows(data_rows)

    lines = [f"# {title}", "", f"- 字段数：{len(headers)}", f"- 内容行数：{len(data_rows)}", ""]
    lines.append("| " + " | ".join(cell.replace("|", "\\|").replace("\n", "<br>") for cell in headers) + " |")
    lines.append("| " + " | ".join("---" for _ in headers) + " |")
    for row in data_rows:
        lines.append("| " + " | ".join(cell.replace("|", "\\|").replace("\n", "<br>") for cell in row) + " |")
    md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return csv_path, md_path, len(data_rows)


def extract_range_rows(response: dict[str, Any]) -> list[list[str]]:
    data = response.get("data", response)
    candidates: list[Any] = []
    if isinstance(data, dict):
        for key in ("values", "cells", "rows", "range_data"):
            if key in data:
                candidates.append(data[key])
    candidates.append(data)
    for candidate in candidates:
        if isinstance(candidate, list) and (not candidate or isinstance(candidate[0], list)):
            return [[normalize_cell(cell) for cell in row] for row in candidate]
        if isinstance(candidate, list) and candidate and isinstance(candidate[0], dict):
            rows: list[list[str]] = []
            for item in candidate:
                values = item.get("values") or item.get("cells") or item
                if isinstance(values, dict):
                    rows.append([normalize_cell(values[key]) for key in sorted(values)])
                elif isinstance(values, list):
                    rows.append([normalize_cell(cell) for cell in values])
            if rows:
                return rows
    return []


def archive_spreadsheet(cli: Path, item: dict[str, str], timeout: int) -> ImportResult:
    archive_id = item["archive_id"]
    title = item["title"]
    archive_dir = Path(item["archive_dir"])
    archive_dir.mkdir(parents=True, exist_ok=True)
    api_dir = archive_dir / "api-json"

    payload = {"docid": item["doc_id"]} if item.get("doc_id") else {"url": item["online_url"]}
    method, properties = run_first_success(
        cli,
        ["spreadsheet_get_sheet_properties", "get_spreadsheet_properties", "get_sheet_properties"],
        payload,
        timeout,
    )
    write_json(api_dir / "spreadsheet-properties.json", {"method": method, "response": properties})
    sheets = find_dicts_with_key(properties, "sheet_id")
    if not sheets:
        raise RuntimeError("spreadsheet properties response did not include any sheet_id")

    total_rows = 0
    sheet_count = 0
    for idx, sheet in enumerate(sheets, start=1):
        sheet_id = str(sheet["sheet_id"])
        name = sheet_title(sheet, f"sheet-{idx}")
        row_count = int_value(sheet, "row_count", "rows", "max_row", default=1000)
        col_count = min(int_value(sheet, "column_count", "col_count", "columns", "max_column", default=200), 200)
        all_rows: list[list[str]] = []
        raw_chunks: list[dict[str, Any]] = []
        for start_row in range(1, max(row_count, 1) + 1, 1000):
            end_row = min(start_row + 999, max(row_count, 1))
            range_name = f"A{start_row}:{col_name(col_count)}{end_row}"
            range_payload = {**payload, "sheet_id": sheet_id, "range": range_name}
            range_method, range_response = run_first_success(
                cli,
                ["spreadsheet_get_sheet_range_data", "get_sheet_range_data", "get_range_data"],
                range_payload,
                timeout,
            )
            raw_chunks.append({"method": range_method, "range": range_name, "response": range_response})
            chunk_rows = extract_range_rows(range_response)
            if start_row > 1 and chunk_rows:
                chunk_rows = chunk_rows[1:] if chunk_rows[0] == all_rows[0] else chunk_rows
            all_rows.extend(chunk_rows)
        stem = slugify(name)
        write_json(api_dir / f"{stem}-range-data.json", raw_chunks)
        _, _, row_total = write_rows(archive_dir, stem, name, all_rows)
        total_rows += row_total
        sheet_count += 1

    return ImportResult(
        archive_id=archive_id,
        title=title,
        source_type=item["source_type"],
        archive_dir=archive_dir,
        status="imported-from-wecom-cli-api",
        rows=total_rows,
        sheets=sheet_count,
        message=f"spreadsheet sheets imported: {sheet_count}",
    )


def record_headers(fields_response: dict[str, Any], records: list[dict[str, Any]]) -> list[str]:
    fields = find_dicts_with_key(fields_response, "field_id")
    headers = [str(item.get("field_title") or item.get("title") or item["field_id"]) for item in fields]
    if headers:
        return headers
    keys: list[str] = []
    for record in records:
        values = record.get("values", {})
        if isinstance(values, dict):
            for key in values:
                if key not in keys:
                    keys.append(key)
    return keys


def archive_smartsheet(cli: Path, item: dict[str, str], timeout: int) -> ImportResult:
    archive_id = item["archive_id"]
    title = item["title"]
    archive_dir = Path(item["archive_dir"])
    archive_dir.mkdir(parents=True, exist_ok=True)
    api_dir = archive_dir / "api-json"
    payload = {"docid": item["doc_id"]} if item.get("doc_id") else {"url": item["online_url"]}
    sheets_response = run_cli(cli, "smartsheet_get_sheet", payload, timeout)
    write_json(api_dir / "smartsheet-sheets.json", sheets_response)
    sheets = find_dicts_with_key(sheets_response, "sheet_id")
    if not sheets:
        raise RuntimeError("smartsheet response did not include any sheet_id")

    total_rows = 0
    for idx, sheet in enumerate(sheets, start=1):
        sheet_id = str(sheet["sheet_id"])
        name = sheet_title(sheet, f"sheet-{idx}")
        fields_response = run_cli(cli, "smartsheet_get_fields", {**payload, "sheet_id": sheet_id}, timeout)
        records_response = run_cli(cli, "smartsheet_get_records", {**payload, "sheet_id": sheet_id}, timeout)
        stem = slugify(name)
        write_json(api_dir / f"{stem}-fields.json", fields_response)
        write_json(api_dir / f"{stem}-records.json", records_response)
        records = records_response.get("records", [])
        headers = record_headers(fields_response, records)
        rows = [headers]
        for record in records:
            values = record.get("values", {}) if isinstance(record, dict) else {}
            rows.append([normalize_cell(values.get(header, "")) for header in headers])
        _, _, row_total = write_rows(archive_dir, stem, name, rows)
        total_rows += row_total

    return ImportResult(
        archive_id=archive_id,
        title=title,
        source_type=item["source_type"],
        archive_dir=archive_dir,
        status="imported-from-wecom-cli-api",
        rows=total_rows,
        sheets=len(sheets),
        message=f"smartsheet sheets imported: {len(sheets)}",
    )


def first_text_field(data: Any, keys: tuple[str, ...]) -> str:
    if isinstance(data, dict):
        for key in keys:
            value = data.get(key)
            if isinstance(value, str) and value.strip():
                return value
        for value in data.values():
            found = first_text_field(value, keys)
            if found:
                return found
    elif isinstance(data, list):
        for item in data:
            found = first_text_field(item, keys)
            if found:
                return found
    return ""


def archive_document(cli: Path, item: dict[str, str], timeout: int, poll_interval: float, max_polls: int) -> ImportResult:
    archive_id = item["archive_id"]
    title = item["title"]
    archive_dir = Path(item["archive_dir"])
    archive_dir.mkdir(parents=True, exist_ok=True)
    payload = {"docid": item["doc_id"], "type": 2} if item.get("doc_id") else {"url": item["online_url"], "type": 2}
    responses: list[dict[str, Any]] = []
    response = run_cli(cli, "get_doc_content", payload, timeout)
    responses.append(response)
    for _ in range(max_polls):
        if response.get("task_done") is True:
            break
        task_id = response.get("task_id")
        if not task_id:
            break
        time.sleep(poll_interval)
        response = run_cli(cli, "get_doc_content", {**payload, "task_id": task_id}, timeout)
        responses.append(response)
    write_json(archive_dir / "api-json" / "document-content.json", responses)
    content = first_text_field(response, ("content", "markdown", "doc_content"))
    if not content:
        raise RuntimeError("document response did not include markdown content")
    (archive_dir / "document.md").write_text(content.rstrip() + "\n", encoding="utf-8")
    return ImportResult(
        archive_id=archive_id,
        title=title,
        source_type=item["source_type"],
        archive_dir=archive_dir,
        status="imported-from-wecom-cli-api",
        rows=0,
        sheets=0,
        message="document markdown imported",
    )


def import_item(cli: Path, item: dict[str, str], timeout: int, poll_interval: float, max_polls: int) -> ImportResult:
    source_type = item.get("source_type", "")
    url = item.get("online_url", "")
    if "smartsheet" in source_type or "/smartsheet/" in url:
        return archive_smartsheet(cli, item, timeout)
    if "sheet" in source_type or "/sheet/" in url:
        try:
            return archive_spreadsheet(cli, item, timeout)
        except RuntimeError as exc:
            message = str(exc)
            if "unrecognized subcommand" in message:
                raise RuntimeError(
                    "official wecom-cli does not expose regular /sheet/ read commands in the current tool list; "
                    "use the xlsx fallback already archived, convert the source to a WeCom smartsheet, "
                    "or use a corp-level Wedoc spreadsheet API credential"
                ) from exc
            raise
    return archive_document(cli, item, timeout, poll_interval, max_polls)


def write_run_report(path: Path, cli: Path | None, ready: bool, readiness: str, results: list[ImportResult]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        "# 企业微信在线文档 API/CLI 全量导入报告",
        "",
        f"- 生成时间：{datetime.now().astimezone().strftime('%Y-%m-%d %H:%M:%S %z')}",
        f"- CLI：`{cli}`" if cli else "- CLI：未就绪",
        f"- 凭证状态：{'已配置' if ready else '未配置'}",
        f"- 凭证说明：{readiness}",
        "",
        "| archive_id | 标题 | 类型 | 状态 | sheet 数 | 行数 | 说明 |",
        "| --- | --- | --- | --- | ---: | ---: | --- |",
    ]
    for result in results:
        lines.append(
            "| {archive_id} | {title} | {source_type} | {status} | {sheets} | {rows} | {message} |".format(
                archive_id=result.archive_id,
                title=result.title,
                source_type=result.source_type,
                status=result.status,
                sheets=result.sheets,
                rows=result.rows,
                message=result.message.replace("|", "\\|"),
            )
        )
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")

    html_path = path.with_suffix(".html")
    body = "\n".join(f"<p>{html.escape(line)}</p>" if line.startswith("- ") else "" for line in lines[:6])
    table_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(result.archive_id)}</td>"
        f"<td>{html.escape(result.title)}</td>"
        f"<td>{html.escape(result.source_type)}</td>"
        f"<td>{html.escape(result.status)}</td>"
        f"<td>{result.sheets}</td>"
        f"<td>{result.rows}</td>"
        f"<td>{html.escape(result.message)}</td>"
        "</tr>"
        for result in results
    )
    html_doc = f"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>企业微信在线文档 API/CLI 全量导入报告</title>
  <style>
    body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #1f2937; }}
    h1 {{ font-size: 28px; }}
    table {{ border-collapse: collapse; width: 100%; margin-top: 24px; }}
    th, td {{ border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; vertical-align: top; }}
    th {{ background: #f3f4f6; }}
    code {{ background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }}
  </style>
</head>
<body>
  <h1>企业微信在线文档 API/CLI 全量导入报告</h1>
  {body}
  <table>
    <thead><tr><th>archive_id</th><th>标题</th><th>类型</th><th>状态</th><th>sheet 数</th><th>行数</th><th>说明</th></tr></thead>
    <tbody>{table_rows}</tbody>
  </table>
</body>
</html>
"""
    html_path.write_text(html_doc, encoding="utf-8")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--index", type=Path, default=DEFAULT_INDEX)
    parser.add_argument("--archive-id", action="append", help="limit import to one or more archive ids")
    parser.add_argument("--auto-install-cli", action="store_true", help="install @wecom/cli into ~/.cache when missing")
    parser.add_argument("--cli-version", default=DEFAULT_CLI_VERSION)
    parser.add_argument("--cache-root", type=Path, default=DEFAULT_CACHE_ROOT)
    parser.add_argument("--execute", action="store_true", help="perform read-only API imports; default only checks readiness")
    parser.add_argument("--timeout", type=int, default=60)
    parser.add_argument("--poll-interval", type=float, default=2.0)
    parser.add_argument("--max-polls", type=int, default=30)
    parser.add_argument(
        "--report",
        type=Path,
        default=Path("work_company_knowledge/online-documents/api-cli-import-report.md"),
    )
    args = parser.parse_args()

    rows = read_index(args.index)
    if args.archive_id:
        wanted = set(args.archive_id)
        rows = [row for row in rows if row.get("archive_id") in wanted]
    results: list[ImportResult] = []
    cli: Path | None = None
    ready = False
    readiness = ""
    try:
        cli = resolve_wecom_cli(args.auto_install_cli, args.cli_version, args.cache_root)
        ready, readiness = check_cli_ready(cli)
    except Exception as exc:  # noqa: BLE001 - report exact setup blocker.
        readiness = str(exc)

    if not cli or not ready:
        for row in rows:
            results.append(
                ImportResult(
                    archive_id=row.get("archive_id", ""),
                    title=row.get("title", ""),
                    source_type=row.get("source_type", ""),
                    archive_dir=Path(row.get("archive_dir", ".")),
                    status="blocked",
                    message=readiness,
                )
            )
        write_run_report(args.report, cli, ready, readiness, results)
        print(f"blocked: {readiness}", file=sys.stderr)
        print(args.report)
        return 2

    if not args.execute:
        for row in rows:
            results.append(
                ImportResult(
                    archive_id=row.get("archive_id", ""),
                    title=row.get("title", ""),
                    source_type=row.get("source_type", ""),
                    archive_dir=Path(row.get("archive_dir", ".")),
                    status="ready-not-executed",
                    message="rerun with --execute to import read-only API content",
                )
            )
        write_run_report(args.report, cli, ready, readiness, results)
        print(args.report)
        return 0

    for row in rows:
        try:
            results.append(import_item(cli, row, args.timeout, args.poll_interval, args.max_polls))
        except Exception as exc:  # noqa: BLE001 - keep going across indexed documents.
            results.append(
                ImportResult(
                    archive_id=row.get("archive_id", ""),
                    title=row.get("title", ""),
                    source_type=row.get("source_type", ""),
                    archive_dir=Path(row.get("archive_dir", ".")),
                    status="failed",
                    message=str(exc),
                )
            )
    write_run_report(args.report, cli, ready, readiness, results)
    print(args.report)
    return 0 if all(result.status == "imported-from-wecom-cli-api" for result in results) else 1


if __name__ == "__main__":
    raise SystemExit(main())
