#!/usr/bin/env python3
"""每日日报主入口：gather + signals → LLM → markdown → git + Gitea。

每天 00:05 cron 触发；也可手动 `python3 run.py --date 2026-05-20 --dry-run` 跑历史。

设计参考 scripts/ops/daily-report/README.md。
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parents[2]

# 共享 helper（extract_signals 仅做信号筛，无副作用，import 安全）
sys.path.insert(0, str(SCRIPT_DIR))
from extract_signals import empty_payload  # noqa: E402

CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("DAILY_REPORT_MODEL", "claude-haiku-4-5-20251001")
LLM_TIMEOUT_SECONDS = 240

ROLLING_BRANCH = "chore/daily-reports-rolling"
GITEA_HOST = os.environ.get("GITEA_HOST", "43.130.59.228")
GITEA_REPO = os.environ.get("GITEA_REPO", "FFAIWorkspace/workspace")


# ---- helpers ---------------------------------------------------------------


def log(msg: str) -> None:
    ts = dt.datetime.now().strftime("%H:%M:%S")
    print(f"[daily-report {ts}] {msg}", file=sys.stderr, flush=True)


def resolve_username() -> str:
    """email local-part with '.' → '-'。跟 daily-report skill gather.sh 算法一致。"""
    try:
        email = subprocess.check_output(["git", "config", "user.email"], text=True).strip()
    except subprocess.CalledProcessError:
        return os.environ.get("USER", "unknown")
    if not email:
        return os.environ.get("USER", "unknown")
    return email.split("@")[0].replace(".", "-")


def run_gather(date_str: str) -> str:
    """跑 .agents/skills/daily-report/scripts/gather.sh，返回原始输出。"""
    gather_path = REPO_ROOT / ".agents" / "skills" / "daily-report" / "scripts" / "gather.sh"
    if not gather_path.exists():
        log(f"WARN gather.sh not found at {gather_path}, skipping commits/PR section")
        return f"(gather.sh missing at {gather_path})"
    try:
        out = subprocess.check_output(
            ["bash", str(gather_path), date_str],
            text=True,
            timeout=120,
            cwd=str(REPO_ROOT),
        )
        return out
    except subprocess.CalledProcessError as e:
        log(f"WARN gather.sh exited {e.returncode}; using partial output")
        return e.output or "(gather.sh failed)"
    except subprocess.TimeoutExpired:
        return "(gather.sh timeout > 120s)"


def run_extract_signals(date_str: str, home: Path) -> dict:
    """跑 extract_signals.py 拿 L1+L2 信号 JSON。"""
    script = SCRIPT_DIR / "extract_signals.py"
    try:
        out = subprocess.check_output(
            ["python3", str(script), "--date", date_str, "--home", str(home)],
            text=True,
            timeout=120,
        )
        return json.loads(out)
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, json.JSONDecodeError) as e:
        log(f"WARN extract_signals failed: {e}; using empty payload")
        return empty_payload(date_str)


# ---- prompt 组装 ----------------------------------------------------------


SYSTEM_PROMPT = """\
你是开发团队的「日报助手」。基于给定的当日数据，按规定 markdown 模板生成结构化日报。

**核心目的不是汇报，是从对话信号里挖工作流可优化点**——重点放「对话洞察」段，少废话。

硬性约束：
- 输出**只能**是日报 markdown 正文，不要解释、不要前后缀、不要 ```markdown 围栏。
- 中文输出。
- 「今日完成」按价值排序，不按时间。引用 PR/commit 用 markdown 链接（基于数据中给的 PR 号 / commit hash）。
- 「对话洞察」必须基于给定的 signals 数据，**翻译成人话 + 给行动建议**——不要直接列规则匹配结果。如果某信号点上下文显示是误报（例如系统注入文本），跳过。
- 「事故/教训」段如果数据里没有今天新增的 .learnings/ERRORS/，就写「今日无新事故」。
- 不写过程废话（"跑了脚本"、"装了依赖"、"读了哪个文件"）。
- 「对话洞察」每条 ≤ 3 行；整篇日报 ≤ 100 行。
- 如果当日完全没有数据（gather 空 + 无 session），输出「（今日无开发活动）」一行即可。
"""

TEMPLATE = """\
# 日报 — {user} · {date}

## 今日完成
（按价值排序的 1-7 条，每条 1 行；含 PR/commit 链接）

## PR / 分支活动
（今天创建/合并/关闭的 PR；分支推进；可空）

## ⭐ 对话洞察（工作流可优化点）

### 反复模式
（同 file 反复改 / 同 tool 序列反复出现 → 建议加 helper / 加 skill 提示）

### 卡点
（tool 连续报错 / 长 turn_gap 后用户调整方向 → 建议加 docs/troubleshoot 或预检 step）

### 决策点
（用户明确给方向调整 / AskUserQuestion 集群 → 留记录方便后续追溯）

## 事故 / 教训
（今天新加的 .learnings/ERRORS/；或「今日无新事故」）
"""


def assemble_prompt(user: str, date_str: str, gather_out: str, signals: dict) -> str:
    sig_section = json.dumps(
        {"sessions": signals.get("sessions", []), "signals": signals.get("signals", [])},
        ensure_ascii=False,
        indent=2,
    )
    return f"""\
日期：{date_str}
作者：{user}

---

【模板】（严格按此结构生成）

{TEMPLATE}

---

【数据 1：gather.sh 原始输出 — commits / PR / 文件 churn / session metadata】

{gather_out}

---

【数据 2：extract_signals.py 抽取的 L2 信号点 + 上下文】

{sig_section}

---

按系统提示词的硬性约束输出 markdown 正文。开始：
"""


# ---- LLM 调用 ---------------------------------------------------------------


def call_llm(prompt: str, system: str, model: str) -> str:
    """通过 claude --print 跑生成；返回 markdown 文本。

    不用 --bare：cron 跑在同一 user 下 OAuth credential 可读；--bare 强制
    ANTHROPIC_API_KEY env 反而增加部署摩擦。代价是会读 auto-memory，对单次
    cron 调用无影响（无 cache 复用场景）。
    """
    cmd = [
        CLAUDE_BIN,
        "--print",
        "--model", model,
        "--append-system-prompt", system,
        "--output-format", "json",
        "--permission-mode", "default",  # 不让 LLM 跑工具
    ]
    log(f"calling LLM: {shlex.join(cmd)} (prompt {len(prompt)} chars)")
    proc = subprocess.run(
        cmd,
        input=prompt,
        capture_output=True,
        text=True,
        timeout=LLM_TIMEOUT_SECONDS,
    )
    # claude CLI 即便 exit≠0 也常把诊断信息塞在 stdout envelope 里（is_error=true / result=错误文本）。
    # 先尝试解析 stdout，失败再 fallback 到 stderr。
    try:
        envelope = json.loads(proc.stdout) if proc.stdout.strip() else None
    except json.JSONDecodeError:
        envelope = None
    if proc.returncode != 0 and not envelope:
        raise RuntimeError(
            f"claude CLI exit {proc.returncode}; stdout={proc.stdout[:500]!r} stderr={proc.stderr[:500]!r}"
        )
    if envelope is None:
        raise RuntimeError(f"claude CLI returned non-JSON; stdout: {proc.stdout[:500]}")
    # claude --print --output-format json envelope: {result, is_error, stop_reason, ...}
    if envelope.get("is_error"):
        raise RuntimeError(f"claude API error: {envelope.get('result') or envelope}")
    result = envelope.get("result", "")
    if not isinstance(result, str) or not result.strip():
        raise RuntimeError(f"claude returned empty result; envelope: {json.dumps(envelope)[:500]}")
    return result.strip()


# ---- 输出 ------------------------------------------------------------------


def write_local_file(user: str, date_str: str, markdown: str) -> Path:
    """写到 slot 工作树里的 daily-reports/<user>/<date>.md（仅手动 / dry-run 用）。

    Cron 模式下不应该写本地——直接走 Gitea API push 到 rolling 分支。
    本函数保留供 dry-run 和手动 debug 用。
    """
    out_dir = REPO_ROOT / "daily-reports" / user
    out_dir.mkdir(parents=True, exist_ok=True)
    p = out_dir / f"{date_str}.md"
    p.write_text(markdown)
    return p


def push_to_rolling_branch(user: str, date_str: str, markdown: str) -> str:
    """通过 Gitea contents API 直接 commit 到 chore/daily-reports-rolling 分支。

    Gitea API:
      GET    /repos/{repo}/contents/{path}?ref=<branch>  → 文件 info (含 sha)；404 = 不存在
      POST   /repos/{repo}/contents/{path}                → 创建（404 后用）
      PUT    /repos/{repo}/contents/{path}                → 更新（需 sha）

    返回 raw URL 供 Gitea issue comment 引用。
    """
    import base64

    sys.path.insert(0, str(REPO_ROOT / "scripts" / "ops"))
    from _gitea_api import Api, get_token  # type: ignore

    token = get_token()
    api = Api(token=token, repo=GITEA_REPO)
    path_in_repo = f"daily-reports/{user}/{date_str}.md"
    api_path = f"/contents/{path_in_repo}"
    raw_url = f"http://{GITEA_HOST}/{GITEA_REPO}/raw/branch/{ROLLING_BRANCH}/{path_in_repo}"

    encoded = base64.b64encode(markdown.encode("utf-8")).decode("ascii")
    commit_msg = f"daily-report({user}): {date_str}"
    body = {"content": encoded, "branch": ROLLING_BRANCH, "message": commit_msg}

    # 拉 sha + PUT/POST，加一次幂等重试规避 race：sha 在 GET 后被别的进程改了 →
    # PUT 返 409/412 → 重新 GET 拿新 sha 再 PUT 一次（同一日报 idempotent，再失败放弃）
    def _upsert_once() -> tuple[int, str]:
        status, resp = api.get(api_path, params={"ref": ROLLING_BRANCH})
        if status == 200:
            try:
                existing = json.loads(resp)
                body["sha"] = existing["sha"]
            except (json.JSONDecodeError, KeyError) as e:
                raise RuntimeError(f"unexpected GET contents response: {resp[:200]}") from e
            st2, resp2 = api.put(api_path, body=body)
            return st2, resp2
        elif status == 404:
            body.pop("sha", None)  # 创建时不该带 sha
            st2, resp2 = api.post(api_path, body=body)
            return st2, resp2
        else:
            raise RuntimeError(f"GET contents {status}: {resp[:300]}")

    st2, resp2 = _upsert_once()
    if st2 in (409, 412):  # sha 失效（并发写），重新拉一次再写
        log(f"  {path_in_repo} sha conflict ({st2}); retrying with fresh sha")
        st2, resp2 = _upsert_once()
    if st2 not in (200, 201):
        raise RuntimeError(f"contents upsert {st2}: {resp2[:300]}")
    log(f"upserted {path_in_repo} on {ROLLING_BRANCH}")
    return raw_url


def post_to_gitea_issue(user: str, date_str: str, markdown: str, raw_url: str) -> None:
    """调 gitea_comment.py append 本周聚合 issue 当日 comment。"""
    script = SCRIPT_DIR / "gitea_comment.py"
    if not script.exists():
        log(f"WARN gitea_comment.py not found; skipping issue post")
        return
    proc = subprocess.run(
        ["python3", str(script), "--user", user, "--date", date_str, "--raw-url", raw_url],
        input=markdown,
        text=True,
        capture_output=True,
        timeout=60,
    )
    if proc.returncode != 0:
        log(f"WARN gitea_comment failed (exit {proc.returncode}): {proc.stderr[:300]}")
    else:
        log(f"posted to Gitea issue: {proc.stdout.strip()}")


# ---- 主流程 ----------------------------------------------------------------


def main() -> int:
    p = argparse.ArgumentParser(description="Generate and publish daily report")
    p.add_argument("--date", default=dt.date.today().isoformat(), help="YYYY-MM-DD")
    p.add_argument("--user", help="override resolved username (required when running for another user; e.g. from run_all dispatcher)")
    p.add_argument("--home", default=str(Path.home()), help="user home for jsonl scan (only used when --signals-file not given)")
    p.add_argument("--gather-file", help="pre-collected gather output (text). If omitted, runs local gather.sh.")
    p.add_argument("--signals-file", help="pre-collected signals JSON. If omitted, runs local extract_signals.py against --home.")
    p.add_argument("--model", default=DEFAULT_MODEL, help="claude model alias or full name")
    p.add_argument("--dry-run", action="store_true", help="don't push to Gitea, just write local file + stdout")
    p.add_argument("--no-gitea", action="store_true", help="skip Gitea issue post (still push md to rolling branch)")
    p.add_argument("--print-prompt", action="store_true", help="dump prompt and exit; don't call LLM")
    args = p.parse_args()

    user = args.user or resolve_username()
    home = Path(args.home)
    opt_out = home / ".claude-insight-optout"
    if opt_out.exists() and not args.signals_file:
        log(f"opted out via {opt_out}; skipping")
        return 0

    log(f"user={user} date={args.date} model={args.model} dry_run={args.dry_run}")

    if args.gather_file:
        gather_out = Path(args.gather_file).read_text(encoding="utf-8", errors="replace")
    else:
        gather_out = run_gather(args.date)

    if args.signals_file:
        try:
            signals = json.loads(Path(args.signals_file).read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as e:
            log(f"ERROR --signals-file unreadable: {e}; treating as empty")
            signals = empty_payload(args.date)
    else:
        signals = run_extract_signals(args.date, home)
    log(f"gather: {len(gather_out)} chars | sessions={len(signals.get('sessions',[]))} signals={len(signals.get('signals',[]))}")

    prompt = assemble_prompt(user, args.date, gather_out, signals)

    if args.print_prompt:
        print(prompt)
        return 0

    if not signals.get("sessions") and not gather_out.strip():
        log("no data today; skipping LLM call")
        markdown = f"# 日报 — {user} · {args.date}\n\n（今日无开发活动）\n"
    else:
        markdown = call_llm(prompt, SYSTEM_PROMPT, args.model)

    if args.dry_run:
        local = write_local_file(user, args.date, markdown)
        log(f"dry-run wrote local: {local}")
        print(markdown)
        return 0

    try:
        raw_url = push_to_rolling_branch(user, args.date, markdown)
    except Exception as e:
        # 不做本地 fallback：runner 上 REPO_ROOT 是 job-ephemeral 路径，写了下次 cron
        # 跑就清，给运维"还能找回"的错觉反而误导。直接失败让 job 显眼，运维查 log
        # 重跑 workflow_dispatch --date <yesterday> 即可（run.py 是幂等的 PUT 操作）。
        log(f"ERROR push to rolling branch failed: {e}")
        return 1

    if not args.no_gitea:
        try:
            post_to_gitea_issue(user, args.date, markdown, raw_url)
        except Exception as e:
            log(f"WARN gitea issue post failed: {e}")
            # 不算失败——文件已经写到 rolling 分支

    return 0


if __name__ == "__main__":
    sys.exit(main())
