#!/usr/bin/env python3
"""
register_contract_cron.py - 根据 contract_reminders.json 注册合同提醒 cron 任务

V1 约束：
1. 仅注册“能计算出明确日期”的提醒
2. 使用 one-shot cron（openclaw cron add --at ...）
3. 使用 --session main + --system-event
4. 默认先移除同名旧任务，再重建，避免重复提醒
5. 支持 dry-run，仅输出计划，不实际写入 cron

典型用法：
  python3 register_contract_cron.py --reminders-json /path/to/contract_reminders.json
  python3 register_contract_cron.py --reminders-json /path/to/contract_reminders.json --dry-run
  cat contract_reminders.json | python3 register_contract_cron.py --stdin --dry-run
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from datetime import date, datetime, time
from pathlib import Path
from typing import Any
try:
    from zoneinfo import ZoneInfo
except ImportError:
    from backports.zoneinfo import ZoneInfo


DEFAULT_TZ = "Asia/Shanghai"
DEFAULT_REMIND_TIME = "09:00"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="根据 contract_reminders.json 注册 cron 任务")
    parser.add_argument("--reminders-json", help="contract_reminders.json 文件路径")
    parser.add_argument("--stdin", action="store_true", help="从 stdin 读取 reminders JSON")
    parser.add_argument("--manifest-output", help="注册结果清单输出路径")
    parser.add_argument("--tz", default=DEFAULT_TZ, help=f"提醒时区，默认 {DEFAULT_TZ}")
    parser.add_argument(
        "--time",
        default=DEFAULT_REMIND_TIME,
        help=f"日期型提醒默认发送时间（HH:MM），默认 {DEFAULT_REMIND_TIME}",
    )
    parser.add_argument("--dry-run", action="store_true", help="仅打印计划，不实际注册 cron")
    parser.add_argument(
        "--skip-remove-existing",
        action="store_true",
        help="不主动移除同名旧任务，默认会先删后建以避免重复提醒",
    )
    return parser.parse_args()


def fail(message: str, exit_code: int = 1) -> None:
    print(message, file=sys.stderr)
    raise SystemExit(exit_code)


def normalize_string(value: Any) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    return text or None


def read_payload(args: argparse.Namespace) -> tuple[dict[str, Any], Path | None]:
    if args.stdin:
        raw = sys.stdin.read()
        source_path = None
    elif args.reminders_json:
        source_path = Path(args.reminders_json).expanduser()
        if not source_path.exists():
            fail(f"输入文件不存在: {source_path}")
        raw = source_path.read_text(encoding="utf-8")
    else:
        fail("请指定 --reminders-json 或 --stdin")

    try:
        payload = json.loads(raw)
    except json.JSONDecodeError as exc:
        fail(f"reminders JSON 解析失败: {exc}")

    if not isinstance(payload, dict):
        fail("reminders JSON 顶层必须是对象")

    return payload, source_path


def ensure_openclaw_available(dry_run: bool) -> None:
    if dry_run:
        return
    if shutil.which("openclaw") is None:
        fail("未找到 openclaw 命令，无法注册 cron。请确认 openclaw 已安装并在 PATH 中。")


def parse_clock(raw: str) -> tuple[int, int]:
    parts = raw.strip().split(":")
    if len(parts) != 2:
        fail("--time 格式错误，应为 HH:MM")
    try:
        hour = int(parts[0])
        minute = int(parts[1])
    except ValueError:
        fail("--time 格式错误，应为 HH:MM")
    if hour < 0 or hour > 23 or minute < 0 or minute > 59:
        fail("--time 超出范围，应为 HH:MM")
    return hour, minute


def parse_date_like(raw: Any) -> date | None:
    text = normalize_string(raw)
    if not text:
        return None
    for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
        try:
            return datetime.strptime(text, fmt).date()
        except ValueError:
            continue
    try:
        return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
    except ValueError:
        return None


def slugify(raw: str, fallback: str) -> str:
    allowed = []
    for ch in raw.strip():
        if ch.isalnum() or ch in {"-", "_"}:
            allowed.append(ch.lower())
        elif ch in {" ", "/", "\\", ":", "：", "|", ".", ","}:
            allowed.append("-")
    text = "".join(allowed)
    while "--" in text:
        text = text.replace("--", "-")
    text = text.strip("-_")
    return text[:48] or fallback


def compute_lead_days(anchor: date | None, reminder_date: date | None) -> int | None:
    if anchor is None or reminder_date is None:
        return None
    return (anchor - reminder_date).days


def build_schedule_iso(reminder_date: date, tz_name: str, hour: int, minute: int) -> str:
    tz = ZoneInfo(tz_name)
    scheduled = datetime.combine(reminder_date, time(hour=hour, minute=minute, tzinfo=tz))
    return scheduled.isoformat()


def build_expiry_message(contract_name: str, item: dict[str, Any], lead_days: int | None) -> str:
    title = normalize_string(item.get("title")) or "合同到期提醒"
    due_date = normalize_string(item.get("dueDate")) or "待确认"
    source_clause = normalize_string(item.get("sourceClause")) or "未标注"
    lead_text = f"将在 {lead_days} 天后到达关键日期" if lead_days is not None else "已到达关键提醒日期"
    notes = normalize_string(item.get("notes")) or "请复核续签、终止、结算及资料交接事项。"
    return (
        f"合同提醒\n"
        f"合同：{contract_name}\n"
        f"事项：{title}\n"
        f"提示：{lead_text}\n"
        f"关键日期：{due_date}\n"
        f"依据：{source_clause}\n"
        f"说明：{notes}"
    )


def build_payment_message(contract_name: str, item: dict[str, Any], lead_days: int | None) -> str:
    title = normalize_string(item.get("title")) or "付款/回款提醒"
    direction = normalize_string(item.get("type")) or "unknown"
    due_date = normalize_string(item.get("dueDate")) or "待确认"
    amount = normalize_string(item.get("amount")) or "未注明"
    source_clause = normalize_string(item.get("sourceClause")) or "未标注"
    trigger = normalize_string(item.get("triggerCondition")) or "无"
    kind = {"payable": "应付提醒", "receivable": "应收提醒"}.get(direction, "款项提醒")
    lead_text = f"距离到期还有 {lead_days} 天" if lead_days is not None else "已到达提醒日期"
    return (
        f"{kind}\n"
        f"合同：{contract_name}\n"
        f"事项：{title}\n"
        f"金额：{amount}\n"
        f"到期日：{due_date}\n"
        f"提示：{lead_text}\n"
        f"触发条件：{trigger}\n"
        f"依据：{source_clause}"
    )


def build_breach_message(contract_name: str, item: dict[str, Any], lead_days: int | None) -> str:
    title = normalize_string(item.get("title")) or "违约风险预警"
    threshold = normalize_string(item.get("thresholdDate")) or "待确认"
    severity = normalize_string(item.get("severity")) or "medium"
    rule = normalize_string(item.get("triggerRule")) or "未说明"
    source_clause = normalize_string(item.get("sourceClause")) or "未标注"
    lead_text = f"距离风险阈值日还有 {lead_days} 天" if lead_days is not None else "已到达风险提示日期"
    return (
        f"违约风险预警\n"
        f"合同：{contract_name}\n"
        f"事项：{title}\n"
        f"风险级别：{severity}\n"
        f"阈值日：{threshold}\n"
        f"提示：{lead_text}\n"
        f"触发规则：{rule}\n"
        f"依据：{source_clause}\n"
        f"说明：该项为风险预警，不代表已实际构成违约，请结合履约事实核实。"
    )


def collect_job_specs(
    payload: dict[str, Any],
    tz_name: str,
    hour: int,
    minute: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    contract = payload.get("contract")
    if not isinstance(contract, dict):
        contract = {}
    contract_name = normalize_string(contract.get("contractName")) or "未命名合同"
    contract_id = normalize_string(contract.get("contractId")) or slugify(contract_name, "contract")
    contract_key = slugify(contract_id, "contract")

    now = datetime.now(ZoneInfo(tz_name))
    jobs: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []

    def add_specs(section_name: str, items: Any, anchor_field: str, message_builder) -> None:
        if not isinstance(items, list):
            return
        for item_idx, item in enumerate(items, start=1):
            if not isinstance(item, dict):
                skipped.append(
                    {"section": section_name, "reason": "item-not-object", "itemIndex": item_idx}
                )
                continue
            title = normalize_string(item.get("title")) or f"{section_name}-{item_idx}"
            anchor_date = parse_date_like(item.get(anchor_field))
            reminder_dates = item.get("suggestedReminderDates")
            if not isinstance(reminder_dates, list) or not reminder_dates:
                skipped.append(
                    {
                        "section": section_name,
                        "title": title,
                        "reason": "missing-suggested-reminder-dates",
                    }
                )
                continue

            title_slug = slugify(title, f"{section_name}-{item_idx}")
            for remind_raw in reminder_dates:
                remind_date = parse_date_like(remind_raw)
                if remind_date is None:
                    skipped.append(
                        {
                            "section": section_name,
                            "title": title,
                            "reason": "invalid-reminder-date",
                            "value": remind_raw,
                        }
                    )
                    continue
                schedule_iso = build_schedule_iso(remind_date, tz_name, hour, minute)
                scheduled_at = datetime.fromisoformat(schedule_iso)
                if scheduled_at <= now:
                    skipped.append(
                        {
                            "section": section_name,
                            "title": title,
                            "reason": "scheduled-time-in-past",
                            "scheduledAt": schedule_iso,
                        }
                    )
                    continue

                lead_days = compute_lead_days(anchor_date, remind_date)
                lead_suffix = f"{lead_days}d" if lead_days is not None else "na"
                anchor_text = anchor_date.isoformat() if anchor_date else "pending"
                job_name = (
                    f"contract-{contract_key}-{section_name}-{anchor_text}-{lead_suffix}-{title_slug}"
                )[:120]
                message = message_builder(contract_name, item, lead_days)
                jobs.append(
                    {
                        "name": job_name,
                        "section": section_name,
                        "title": title,
                        "scheduledAt": schedule_iso,
                        "anchorDate": anchor_text,
                        "leadDays": lead_days,
                        "message": message,
                    }
                )

    add_specs("expiry", payload.get("expiryReminders"), "dueDate", build_expiry_message)
    add_specs("payment", payload.get("paymentReminders"), "dueDate", build_payment_message)
    add_specs("breach", payload.get("breachRiskAlerts"), "thresholdDate", build_breach_message)
    return jobs, skipped


def run_openclaw_json(command: list[str]) -> dict[str, Any]:
    completed = subprocess.run(command, capture_output=True, text=True)
    if completed.returncode != 0:
        stderr = completed.stderr.strip() or completed.stdout.strip() or "未知错误"
        raise RuntimeError(stderr)
    stdout = completed.stdout.strip()
    if not stdout:
        return {}
    try:
        return json.loads(stdout)
    except json.JSONDecodeError:
        return {"raw": stdout}


def list_existing_jobs() -> list[dict[str, Any]]:
    response = run_openclaw_json(["openclaw", "cron", "list", "--all", "--json"])
    jobs = response.get("jobs")
    return jobs if isinstance(jobs, list) else []


def remove_job(job_id: str) -> dict[str, Any]:
    return run_openclaw_json(["openclaw", "cron", "remove", job_id, "--json"])


def add_job(spec: dict[str, Any]) -> dict[str, Any]:
    return run_openclaw_json(
        [
            "openclaw",
            "cron",
            "add",
            "--name",
            spec["name"],
            "--at",
            spec["scheduledAt"],
            "--session",
            "main",
            "--system-event",
            spec["message"],
            "--delete-after-run",
            "--json",
        ]
    )


def resolve_manifest_output(args: argparse.Namespace, source_path: Path | None) -> Path | None:
    if args.manifest_output:
        return Path(args.manifest_output).expanduser()
    if source_path is None:
        return None
    return source_path.with_name("contract_cron_manifest.json")


def main() -> int:
    args = parse_args()
    ensure_openclaw_available(args.dry_run)
    payload, source_path = read_payload(args)
    hour, minute = parse_clock(args.time)
    jobs, skipped = collect_job_specs(payload, args.tz, hour, minute)

    planned_names = {spec["name"] for spec in jobs}
    removed: list[dict[str, Any]] = []
    created: list[dict[str, Any]] = []
    failed: list[dict[str, Any]] = []

    if not args.skip_remove_existing and planned_names:
        try:
            existing_jobs = [] if args.dry_run else list_existing_jobs()
        except Exception as exc:
            fail(f"读取现有 cron 任务失败: {exc}")

        for job in existing_jobs:
            if not isinstance(job, dict):
                continue
            name = normalize_string(job.get("name"))
            job_id = normalize_string(job.get("id"))
            if not name or not job_id or name not in planned_names:
                continue
            if args.dry_run:
                removed.append({"id": job_id, "name": name, "status": "would-remove"})
                continue
            try:
                remove_job(job_id)
                removed.append({"id": job_id, "name": name, "status": "removed"})
            except Exception as exc:
                failed.append({"name": name, "stage": "remove-existing", "error": str(exc)})

    for spec in jobs:
        if args.dry_run:
            created.append(
                {
                    "name": spec["name"],
                    "section": spec["section"],
                    "scheduledAt": spec["scheduledAt"],
                    "status": "would-create",
                }
            )
            continue
        try:
            response = add_job(spec)
            created.append(
                {
                    "name": spec["name"],
                    "section": spec["section"],
                    "scheduledAt": spec["scheduledAt"],
                    "status": "created",
                    "response": response,
                }
            )
        except Exception as exc:
            failed.append(
                {
                    "name": spec["name"],
                    "section": spec["section"],
                    "scheduledAt": spec["scheduledAt"],
                    "stage": "create",
                    "error": str(exc),
                }
            )

    contract = payload.get("contract")
    if not isinstance(contract, dict):
        contract = {}

    manifest = {
        "schemaVersion": "1.0",
        "generatedAt": datetime.now().isoformat(timespec="seconds"),
        "dryRun": bool(args.dry_run),
        "contract": {
            "contractName": normalize_string(contract.get("contractName")) or "未命名合同",
            "contractId": normalize_string(contract.get("contractId")),
        },
        "summary": {
            "planned": len(jobs),
            "removed": len(removed),
            "created": len(created),
            "failed": len(failed),
            "skipped": len(skipped),
        },
        "removed": removed,
        "created": created,
        "failed": failed,
        "skipped": skipped,
    }

    manifest_output = resolve_manifest_output(args, source_path)
    if manifest_output is not None:
        manifest_output.parent.mkdir(parents=True, exist_ok=True)
        manifest_output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    print(json.dumps(manifest, ensure_ascii=False, indent=2))
    return 0 if not failed else 2


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