#!/usr/bin/env python3
"""Platform-neutral Deep Research CLI for Codex and other adapters.

This CLI intentionally keeps deterministic orchestration in Python while
allowing Codex/OpenCode/Gemini/Claude Code to provide the agentic layer.
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(REPO_ROOT))

from scripts.lib.model_config import (
    ModelConfigError,
    list_model_profiles,
    parse_model_overrides,
    resolve_model_profile,
)
from scripts.runtime.assembly import build_chapter_briefs, build_compressed_findings, run_chapter_assembly_workers
from scripts.runtime.orchestrator import create_phase2_task_cards, write_placeholder_packets
from scripts.runtime.methods import ResearchMethodRegistry
from scripts.runtime.phase1 import create_project, render_framework, write_material_brief
from scripts.runtime.review import build_phase3_critique, build_phase3_model_critique
from scripts.runtime.roles import resolve_runtime_profile
from scripts.runtime.source_cache import cache_sources
from scripts.runtime.sources import rebuild_sources_from_packets
from scripts.runtime.skills import SkillRegistry, default_adapter_skill_dirs
from scripts.runtime.tasks import TaskCard, load_task_cards, validate_packet, write_task_cards
from scripts.runtime.workers import run_packet_workers


PROJECTS_DIR = REPO_ROOT / "projects"
CODEX_COMMAND_TEMPLATES_DIR = REPO_ROOT / "codex_adapter_templates" / "codex" / "commands"
LEGACY_CODEX_COMMANDS_DIR = REPO_ROOT / ".codex" / "commands"


def resolve_project(project: str | None, *, projects_dir: Path = PROJECTS_DIR) -> Path:
    if project:
        p = Path(project)
        if p.is_dir():
            return p.resolve()
        cand = projects_dir / project
        if cand.is_dir():
            return cand.resolve()
        raise SystemExit(f"project not found: {project}")

    manifests = sorted(
        projects_dir.glob("*/manifest.json"),
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    if not manifests:
        raise SystemExit("no projects found")
    return manifests[0].parent.resolve()


def load_manifest(project_root: Path) -> dict:
    path = project_root / "manifest.json"
    if not path.exists():
        raise SystemExit(f"manifest not found: {path}")
    return json.loads(path.read_text(encoding="utf-8"))


def count_words(text: str) -> int:
    return len(re.findall(r"[A-Za-z]+(?:[-'][A-Za-z]+)*", text))


def count_chinese_chars(text: str) -> int:
    return sum(1 for c in text if "\u4e00" <= c <= "\u9fff")


def file_state(path: Path) -> str:
    return "yes" if path.exists() else "no"


def packet_state_counts(project_root: Path) -> dict[str, int]:
    packets = sorted((project_root / "phase2" / "packets").glob("*.json"))
    errors = sorted((project_root / "phase2" / "packet_errors").glob("*.json"))
    counts = {
        "ready": 0,
        "placeholder": 0,
        "invalid": 0,
        "errors": 0,
        "stale_errors": 0,
        "total": len(packets),
    }
    ready_stems: set[str] = set()
    for path in packets:
        try:
            packet = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            counts["invalid"] += 1
            continue
        has_evidence = bool(
            packet.get("claims")
            or packet.get("evidence_items")
            or packet.get("counter_evidence")
            or packet.get("source_ids")
        )
        if has_evidence:
            counts["ready"] += 1
            ready_stems.add(path.stem)
        else:
            counts["placeholder"] += 1
    for path in errors:
        if path.stem in ready_stems:
            counts["stale_errors"] += 1
        else:
            counts["errors"] += 1
    return counts


def run_cmd(cmd: list[str], *, dry_run: bool) -> int:
    printable = " ".join(cmd)
    print(f"$ {printable}")
    if dry_run:
        return 0
    return subprocess.run(cmd, cwd=REPO_ROOT, check=False).returncode


def cmd_init(args: argparse.Namespace) -> int:
    projects_dir = Path(args.projects_dir) if args.projects_dir else PROJECTS_DIR
    project_root = create_project(
        topic=args.topic,
        slug=args.slug,
        projects_dir=projects_dir,
        method_key=args.method,
        report_type=args.report_type,
        model_profile=args.profile,
        target_words=args.target_words,
        input_materials=args.input_material,
    )
    print(f"Project: {project_root.name}")
    print(f"Created: {project_root}")
    print("Runtime: python-core-v0.20")
    print("Next: run `dr.py frame <project>` to generate phase1/framework.md")
    return 0


def cmd_frame(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    if args.dry_run:
        manifest = load_manifest(project_root)
        method = ResearchMethodRegistry().get(args.method or manifest.get("research_method"))
        print(f"Project: {project_root.name}")
        print(f"Would write: phase1/framework.md")
        print(f"Research method: {method.key}")
        print(f"Chapters: {args.chapters}")
        return 0
    path = render_framework(
        project_root,
        method_key=args.method,
        chapter_count=args.chapters,
        preserve_existing_outline=args.preserve_existing_outline,
    )
    print(f"Project: {project_root.name}")
    print(f"Wrote: {path.relative_to(project_root)}")
    print("Pause: review and approve the framework before Phase 2.")
    return 0


def cmd_approve(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    manifest = load_manifest(project_root)
    phase1 = manifest.setdefault("phase1", {})
    phase1["approved"] = True
    phase1["requires_user_interview"] = False
    phase1["approved_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
    manifest["updated_at"] = phase1["approved_at"]
    (project_root / "manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    print(f"Project: {project_root.name}")
    print("Phase 1 approved. Phase 2 research is now enabled.")
    return 0


def cmd_skills(args: argparse.Namespace) -> int:
    registry = SkillRegistry()
    if args.skills_cmd == "list":
        for name in registry.list_names():
            print(name)
        return 0
    if args.skills_cmd == "validate":
        result = registry.validate()
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0 if result["ok"] else 1
    if args.skills_cmd == "sync":
        targets = [Path(item) for item in args.target] if args.target else default_adapter_skill_dirs()
        copied = registry.sync_to(targets, force=True)
        print(f"Synced skills: {copied}")
        for target in targets:
            print(f"  {target}")
        return 0
    raise SystemExit(f"unknown skills command: {args.skills_cmd}")


def cmd_methods(args: argparse.Namespace) -> int:
    registry = ResearchMethodRegistry()
    if args.methods_cmd == "list":
        for name in registry.list_names():
            method = registry.get(name)
            print(f"{method.key}: {method.name}")
        return 0
    if args.methods_cmd == "show":
        method = registry.get(args.method)
        print(json.dumps(method.__dict__, ensure_ascii=False, indent=2))
        return 0
    raise SystemExit(f"unknown methods command: {args.methods_cmd}")


def cmd_research(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    manifest = load_manifest(project_root)
    if not args.force and not (manifest.get("phase1") or {}).get("approved"):
        raise SystemExit(
            "Phase 1 is not approved. Review phase1/material_brief.md and phase1/framework.md, "
            "then run `uv run python scripts/dr.py approve <project>` or pass --force."
        )
    if args.profile == "codex_native" and (args.execute_packets or args.assemble_chapters):
        raise SystemExit(
            "`codex_native` cannot be used for Python-core model execution: scripts/dr.py currently calls external "
            "API clients, not Codex App built-in models. Use a clearly external profile such as `medium`, or run "
            "Codex-native execution through the surface adapter/manual task workflow."
        )
    runtime = resolve_runtime_profile(
        profile=args.profile,
        overrides=parse_model_overrides(args.model_override),
    )
    if args.append_task_cards:
        generated = create_phase2_task_cards(
            project_root,
            axes=args.axis,
            dry_run=True,
        )
        existing_path = project_root / "phase2" / "task_cards.json"
        existing_cards = load_task_cards(existing_path) if existing_path.exists() else []
        seen = {card.task_id for card in existing_cards}
        appended_cards = [TaskCard(**item) for item in generated if item["task_id"] not in seen]
        runnable_existing_cards: list[TaskCard] = []
        if args.execute_packets and args.axis:
            axis_set = set(args.axis)
            for card in existing_cards:
                if card.topic_axis not in axis_set:
                    continue
                packet_path = project_root / card.output_packet
                try:
                    validate_packet(json.loads(packet_path.read_text(encoding="utf-8")))
                except Exception:
                    runnable_existing_cards.append(card)
        merged_cards = [*existing_cards, *appended_cards]
        if not args.dry_run:
            write_task_cards(existing_path, merged_cards)
            phase2 = manifest.setdefault("phase2", {})
            phase2.update(
                {
                    "status": "in_progress",
                    "runtime": "python-core-v0.20",
                    "task_cards_path": "phase2/task_cards.json",
                    "task_cards_total": len(merged_cards),
                    "task_cards_appended": len(appended_cards),
                    "updated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
                }
            )
            (project_root / "manifest.json").write_text(
                json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
                encoding="utf-8",
            )
        card_dicts = [card.to_dict() for card in [*appended_cards, *runnable_existing_cards]]
    else:
        card_dicts = create_phase2_task_cards(
            project_root,
            axes=args.axis,
            dry_run=args.dry_run,
        )
    if args.execute_packets and args.dry_run:
        raise SystemExit("--execute-packets cannot be combined with --dry-run")
    if args.assemble_chapters and args.dry_run:
        raise SystemExit("--assemble-chapters cannot be combined with --dry-run")
    if args.execute_packets:
        from scripts.lib.zenmux_client import ZenMuxClient, load_secrets
        from scripts.runtime.workers import ProjectSearchProvider

        load_secrets()

        def client_factory(_role):
            return ZenMuxClient(log_file=project_root / "phase2" / "logs" / "packets.jsonl")

        def search_provider_factory():
            return ProjectSearchProvider(strict_specialized=not args.allow_search_fallback)

        packet_count = run_packet_workers(
            project_root=project_root,
            cards=[TaskCard(**item) for item in card_dicts],
            runtime=runtime,
            client_factory=client_factory,
            search_provider_factory=search_provider_factory,
            workers=args.workers,
        )
    elif not (args.build_briefs or args.assemble_chapters):
        packet_count = write_placeholder_packets(project_root, card_dicts, dry_run=args.dry_run)
    else:
        packet_count = len(list((project_root / "phase2" / "packets").glob("*.json")))
    brief_count = 0
    chapter_count = 0
    compressed_count = 0
    source_count = None
    if args.build_briefs or args.assemble_chapters:
        source_count = rebuild_sources_from_packets(project_root)
        briefs = build_chapter_briefs(project_root)
        brief_count = len(briefs)
        compressed_findings = build_compressed_findings(project_root)
        compressed_count = len(compressed_findings)
        if args.assemble_chapters:
            from scripts.lib.zenmux_client import ZenMuxClient, load_secrets

            load_secrets()

            def chapter_client_factory(_role):
                return ZenMuxClient(log_file=project_root / "phase2" / "logs" / "chapters.jsonl")

            chapter_count = run_chapter_assembly_workers(
                project_root=project_root,
                briefs=compressed_findings,
                runtime=runtime,
                client_factory=chapter_client_factory,
                workers=args.workers,
            )
    print(f"Project: {project_root.name}")
    print(f"Runtime: python-core-v0.20")
    print(f"Model profile: {runtime.profile}")
    print(f"Workers: {args.workers}")
    print(f"Task cards: {len(card_dicts)}")
    print(f"Packets: {packet_count}")
    if source_count is not None:
        print(f"Sources rebuilt: {source_count}")
    if args.build_briefs or args.assemble_chapters:
        print(f"Chapter briefs: {brief_count}")
        print(f"Compressed findings: {compressed_count}")
    if args.assemble_chapters:
        print(f"Chapter drafts: {chapter_count}")
    if args.dry_run:
        print("Dry run: no files written")
    elif args.assemble_chapters:
        print("Wrote: phase2/drafts/chXX.md")
    elif args.execute_packets:
        print("Wrote: phase2/task_cards.json and validated phase2/packets/*.json")
        print("Next: rerun with --build-briefs to aggregate packets into chapter briefs.")
    elif args.build_briefs:
        print("Wrote: phase2/chapter_briefs/*.json and phase2/compressed_findings/*.json")
        print("Next: rerun with --assemble-chapters to write Chinese chapter drafts.")
    else:
        print("Wrote: phase2/task_cards.json and phase2/packets/*.json")
        print("Next: rerun with --execute-packets to fill packets via model workers.")
    return 0


def cmd_run(args: argparse.Namespace) -> int:
    target = args.project_or_topic
    projects_dir = Path(args.projects_dir) if args.projects_dir else PROJECTS_DIR
    try:
        project_root = resolve_project(target, projects_dir=projects_dir)
    except SystemExit:
        if args.dry_run:
            print(f"New topic detected: {target}")
            print("Dry run: would create project and write phase1/framework.md")
            return 0
        project_root = create_project(
            topic=target,
            slug=args.slug,
            projects_dir=projects_dir,
            method_key=args.method,
            report_type=args.report_type,
            model_profile=args.profile or "medium",
            target_words=args.target_words,
            input_materials=args.input_material,
        )
        framework = render_framework(project_root, chapter_count=args.chapters)
        print(f"Project: {project_root.name}")
        print("Runtime: python-core-v0.20")
        print(f"Created: {project_root}")
        print(f"Wrote: {framework.relative_to(project_root)}")
        print("Pause: review and approve the framework before Phase 2.")
        return 0

    print(f"Project: {project_root.name}")
    print("Runtime: python-core-v0.20")
    print("Next command: research")
    if args.dry_run:
        print("Dry run: would inspect manifest and continue from the next incomplete phase")
        return 0
    return cmd_research(
        argparse.Namespace(
            project=str(project_root),
            workers=args.workers,
            axis=None,
            profile=args.profile,
            model_override=[],
            execute_packets=False,
            append_task_cards=False,
            allow_search_fallback=False,
            build_briefs=False,
            assemble_chapters=False,
            force=False,
            dry_run=False,
        )
    )


def cmd_review(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    if args.model_review:
        if args.dry_run:
            print(f"Project: {project_root.name}")
            print("Phase 3 model review plan:")
            print(f"  model: {args.model}")
            print("  context: phase3/review_context_opus_4_7.md")
            print("  output: phase3/critique.md")
            return 0
        from scripts.lib.zenmux_client import ZenMuxClient, load_secrets

        load_secrets()
        with ZenMuxClient(log_file=project_root / "phase3" / "logs" / "review.jsonl") as client:
            path = build_phase3_model_critique(
                project_root,
                client=client,
                model=args.model,
                max_context_chars=args.max_context_chars,
            )
    else:
        path = build_phase3_critique(project_root)
    print(f"Project: {project_root.name}")
    print(f"Wrote: {path.relative_to(project_root)}")
    print("Pause: review critique before Phase 4.")
    return 0


def cmd_status(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    manifest = load_manifest(project_root)
    slug = project_root.name

    drafts = sorted((project_root / "phase2" / "drafts").glob("ch*.md"))
    evidence = sorted((project_root / "phase2" / "evidence").glob("ch*-evidence.md"))
    task_cards = project_root / "phase2" / "task_cards.json"
    research_brief = project_root / "phase1" / "research_brief.json"
    compressed_findings = sorted((project_root / "phase2" / "compressed_findings").glob("ch*.json"))
    packet_counts = packet_state_counts(project_root)
    sources = project_root / "phase2" / "sources.jsonl"
    final_en = project_root / "phase4" / "final_en.md"
    final_zh = project_root / "phase4" / "final_zh.md"
    final_zh_polished = project_root / "phase4" / "final_zh_polished.md"
    glossary = project_root / "phase4" / "glossary.json"

    en_words = count_words(final_en.read_text(encoding="utf-8")) if final_en.exists() else 0
    zh_source = final_zh_polished if final_zh_polished.exists() else final_zh
    zh_chars = count_chinese_chars(zh_source.read_text(encoding="utf-8")) if zh_source.exists() else 0
    source_count = 0
    if sources.exists():
        source_count = sum(1 for line in sources.read_text(encoding="utf-8").splitlines() if line.strip())

    print(f"Project: {manifest.get('topic', slug)}")
    print(f"Slug: {slug}")
    print(f"Title: {manifest.get('report_title', '(unset)')}")
    print(f"Type: {manifest.get('type', '(unset)')}")
    print()
    print("Phases:")
    for phase in ("phase1", "phase2", "phase3", "phase4"):
        p = manifest.get(phase, {})
        print(f"  {phase}: {p.get('status', 'pending')}  approved={p.get('approved', False)}")
    print()
    print("Artifacts:")
    print(f"  framework: {file_state(project_root / 'phase1' / 'framework.md')}")
    print(f"  research_brief.json: {file_state(research_brief)}")
    print(f"  task_cards.json: {file_state(task_cards)}")
    print(
        "  packets: "
        f"ready={packet_counts['ready']} "
        f"placeholder={packet_counts['placeholder']} "
        f"invalid={packet_counts['invalid']} "
        f"errors={packet_counts['errors']} "
        f"stale_errors={packet_counts['stale_errors']} "
        f"total={packet_counts['total']}"
    )
    print(f"  drafts: {len(drafts)}")
    print(f"  compressed findings: {len(compressed_findings)}")
    print(f"  evidence files: {len(evidence)}")
    print(f"  sources: {source_count}")
    print(f"  final_en.md: {file_state(final_en)} ({en_words:,} words)")
    print(f"  final_zh.md: {file_state(final_zh)}")
    print(f"  final_zh_polished.md: {file_state(final_zh_polished)} ({zh_chars:,} Chinese chars)")
    print(f"  glossary.json: {file_state(glossary)}")
    return 0


def cmd_prompt(args: argparse.Namespace) -> int:
    name = args.command
    if not name.startswith("dr-"):
        name = f"dr-{name}"
    candidates = [
        CODEX_COMMAND_TEMPLATES_DIR / f"{name}.md",
        LEGACY_CODEX_COMMANDS_DIR / f"{name}.md",
    ]
    path = next((candidate for candidate in candidates if candidate.exists()), None)
    if path is None:
        raise SystemExit(f"Codex command template not found: {candidates[0]}")

    text = path.read_text(encoding="utf-8")
    if args.argument:
        text = text.replace("$ARGUMENTS", args.argument)
    else:
        text = text.replace("$ARGUMENTS", "")
    print(text)
    return 0


def cmd_glossary(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    cmd = [
        sys.executable,
        str(REPO_ROOT / "scripts" / "build_glossary.py"),
        str(project_root),
        "--workers",
        str(args.workers),
    ]
    if args.force:
        cmd.append("--force")
    if args.only:
        cmd += ["--only", args.only]
    if args.input:
        cmd += ["--input", args.input]
    if args.output:
        cmd += ["--output", args.output]
    return run_cmd(cmd, dry_run=args.dry_run)


def cmd_finalize(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    manifest = load_manifest(project_root)
    effective_profile = args.model_profile or manifest.get("model_profile")
    try:
        resolved = resolve_model_profile(
            profile=effective_profile,
            overrides=parse_model_overrides(args.model_override),
        )
    except ModelConfigError as exc:
        raise SystemExit(f"model profile resolution failed: {exc}") from exc
    roles = resolved["roles"]

    if not args.legacy_translate:
        final_input = args.input
        if args.number_citations and not args.dry_run:
            rc = run_cmd(
                [
                    sys.executable,
                    str(REPO_ROOT / "scripts" / "number_citations.py"),
                    str(project_root),
                    "--input",
                    args.input,
                    "--output",
                    args.numbered_output,
                ],
                dry_run=False,
            )
            if rc != 0:
                return rc
            final_input = args.numbered_output
        elif args.number_citations:
            final_input = args.numbered_output
        cmd: list[str] = [
            sys.executable,
            str(REPO_ROOT / "scripts" / "build_report.py"),
            str(project_root),
            "--input",
            final_input,
        ]
        if args.report_engine:
            cmd += ["--engine", args.report_engine]
        if args.no_docx:
            cmd.append("--no-docx")
        if args.no_pdf:
            cmd.append("--no-pdf")
        if args.dry_run:
            print("Chinese-native finalize plan:")
            if args.number_citations:
                print(
                    "$ "
                    + " ".join(
                        [
                            sys.executable,
                            str(REPO_ROOT / "scripts" / "number_citations.py"),
                            str(project_root),
                            "--input",
                            args.input,
                            "--output",
                            args.numbered_output,
                        ]
                    )
                )
            print("$ " + " ".join(cmd))
            if args.polish:
                print(
                    "$ "
                    + " ".join(
                        [
                            sys.executable,
                            str(REPO_ROOT / "scripts" / "polish.py"),
                            str(project_root),
                            "--source",
                            final_input,
                            "--workers",
                            str(args.polish_workers),
                            "--model",
                            roles.get("polish", "anthropic/claude-sonnet-4.6"),
                        ]
                    )
                )
            return 0
        if args.polish:
            rc = run_cmd(
                [
                    sys.executable,
                    str(REPO_ROOT / "scripts" / "polish.py"),
                    str(project_root),
                    "--source",
                    final_input,
                    "--workers",
                    str(args.polish_workers),
                    "--model",
                    roles.get("polish", "anthropic/claude-sonnet-4.6"),
                ],
                dry_run=False,
            )
            if rc != 0:
                return rc
        return run_cmd(cmd, dry_run=False)

    cmd = [
        sys.executable,
        str(REPO_ROOT / "scripts" / "phase4_pipeline.py"),
        str(project_root),
        "--translate-workers",
        str(args.translate_workers),
        "--glossary-workers",
        str(args.glossary_workers),
        "--polish-workers",
        str(args.polish_workers),
        "--translate-model",
        roles.get("translate", "anthropic/claude-sonnet-4.6"),
        "--glossary-model",
        roles.get("glossary", "anthropic/claude-haiku-4.5"),
        "--polish-model",
        roles.get("polish", "anthropic/claude-sonnet-4.6"),
        "--glossary-mode",
        args.glossary_mode,
    ]
    if args.dry_run:
        cmd.append("--dry-run")
    return run_cmd(cmd, dry_run=False)


def cmd_models(args: argparse.Namespace) -> int:
    if args.list:
        for name in list_model_profiles():
            print(name)
        return 0

    try:
        resolved = resolve_model_profile(
            profile=args.profile,
            overrides=parse_model_overrides(args.model_override),
        )
    except ModelConfigError as exc:
        raise SystemExit(f"model profile resolution failed: {exc}") from exc

    if args.probe:
        from scripts.lib.zenmux_client import ZenMuxClient, load_secrets, normalize_zenmux_model

        load_secrets()
        results = []
        with ZenMuxClient() as client:
            for requested_model in sorted(set(resolved["roles"].values())):
                api_model = normalize_zenmux_model(requested_model)
                try:
                    content = client.chat_complete(
                        model=requested_model,
                        system="Health check.",
                        user="Reply with OK only.",
                        temperature=0,
                        max_tokens=16,
                        tag=f"models:probe:{requested_model}",
                    )
                    results.append({
                        "requested_model": requested_model,
                        "api_model": api_model,
                        "ok": True,
                        "response": content.strip()[:80],
                    })
                except Exception as exc:  # noqa: BLE001 - probe should report every model.
                    results.append({
                        "requested_model": requested_model,
                        "api_model": api_model,
                        "ok": False,
                        "error": str(exc)[:500],
                    })

        if args.json:
            print(json.dumps({**resolved, "probe": results}, ensure_ascii=False, indent=2))
        else:
            print(f"Profile: {resolved['profile']}")
            print("Model probe:")
            for item in results:
                status = "ok" if item["ok"] else "fail"
                print(f"  {status}  {item['requested_model']} -> {item['api_model']}")
                if not item["ok"]:
                    print(f"        {item['error']}")
        return 0 if all(item["ok"] for item in results) else 1

    if args.json:
        print(json.dumps(resolved, ensure_ascii=False, indent=2))
        return 0

    print(f"Profile: {resolved['profile']}")
    if resolved["description"]:
        print(f"Description: {resolved['description']}")
    print("Roles:")
    for role in sorted(resolved["roles"]):
        print(f"  {role}: {resolved['roles'][role]}")
    if resolved.get("task_types"):
        print("Task types:")
        for task_type in sorted(resolved["task_types"]):
            print(f"  {task_type}: {resolved['task_types'][task_type]}")
    return 0


def cmd_sources(args: argparse.Namespace) -> int:
    project_root = resolve_project(args.project)
    if args.sources_cmd == "cache":
        if args.dry_run:
            print(f"Project: {project_root.name}")
            print(f"Would cache sources from: {args.sources}")
            print(f"Important only: {not args.all}")
            print(f"Limit: {args.limit}")
            return 0
        results = cache_sources(
            project_root,
            sources_rel=args.sources,
            important_only=not args.all,
            limit=args.limit,
            force=args.force,
        )
        print(f"Project: {project_root.name}")
        print(f"Cached source snapshots: {len(results)}")
        print("Wrote: phase2/source_cache/md/*.md")
        print("Updated: phase2/sources.jsonl")
        return 0
    raise SystemExit(f"unknown sources command: {args.sources_cmd}")


def cmd_apply_models(args: argparse.Namespace) -> int:
    cmd = [
        sys.executable,
        str(REPO_ROOT / "scripts" / "apply_model_profile.py"),
        "--profile",
        args.profile,
        "--target",
        args.target,
    ]
    for item in args.model_override:
        cmd += ["--model-override", item]
    if args.dry_run:
        cmd.append("--dry-run")
    return run_cmd(cmd, dry_run=False)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Deep Research platform-neutral CLI")
    sub = parser.add_subparsers(dest="cmd", required=True)

    init = sub.add_parser("init", help="Initialize a Python-core research project")
    init.add_argument("topic", help="Research topic")
    init.add_argument("--slug", help="Project slug")
    init.add_argument("--method", help="Research method key")
    init.add_argument("--type", dest="report_type", default="research", help="Report type")
    init.add_argument("--profile", default="medium", help="Model profile name from configs/models.yaml")
    init.add_argument("--target-words", type=int, default=30000)
    init.add_argument("--input-material", action="append", default=[], help="Path or note for user-provided material")
    init.add_argument("--projects-dir", help="Override projects directory")
    init.set_defaults(func=cmd_init)

    approve = sub.add_parser("approve", help="Approve Phase 1 gates before Phase 2")
    approve.add_argument("project", help="Project slug or path")
    approve.set_defaults(func=cmd_approve)

    frame = sub.add_parser("frame", help="Generate Phase 1 framework.md")
    frame.add_argument("project", help="Project slug or path")
    frame.add_argument("--method", help="Override research method key")
    frame.add_argument("--chapters", type=int, default=10)
    frame.add_argument("--preserve-existing-outline", action="store_true", help="Keep current framework chapter titles and enrich Phase 1 planning")
    frame.add_argument("--dry-run", action="store_true")
    frame.set_defaults(func=cmd_frame)

    run = sub.add_parser("run", help="Run the platform-neutral Python-core workflow")
    run.add_argument("project_or_topic", help="Project slug/path or new topic")
    run.add_argument("--workers", type=int, default=6)
    run.add_argument("--profile", help="Model profile name from configs/models.yaml")
    run.add_argument("--slug", help="Project slug when project_or_topic is new")
    run.add_argument("--method", help="Research method key when project_or_topic is new")
    run.add_argument("--type", dest="report_type", default="research", help="Report type for new project")
    run.add_argument("--target-words", type=int, default=30000)
    run.add_argument("--chapters", type=int, default=10)
    run.add_argument("--input-material", action="append", default=[])
    run.add_argument("--projects-dir", help="Override projects directory")
    run.add_argument("--dry-run", action="store_true")
    run.set_defaults(func=cmd_run)

    research = sub.add_parser("research", help="Run v0.20 task-card Phase 2")
    research.add_argument("project", help="Project slug or path")
    research.add_argument("--workers", type=int, default=6)
    research.add_argument("--axis", action="append", help="Restrict generated task axes; repeatable")
    research.add_argument("--profile", help="Model profile name from configs/models.yaml")
    research.add_argument("--model-override", action="append", default=[], metavar="ROLE=MODEL", help="Override a role model for this run; repeatable")
    research.add_argument("--execute-packets", action="store_true", help="Call model workers to fill evidence packets")
    research.add_argument("--append-task-cards", action="store_true", help="Append newly generated task cards instead of replacing phase2/task_cards.json")
    research.add_argument("--allow-search-fallback", action="store_true", help="Allow generic search fallback for specialized routes")
    research.add_argument("--build-briefs", action="store_true", help="Aggregate packets into chapter briefs")
    research.add_argument("--assemble-chapters", action="store_true", help="Call model workers to write Chinese chapter drafts")
    research.add_argument("--force", action="store_true", help="bypass Phase 1 approval gate")
    research.add_argument("--dry-run", action="store_true")
    research.set_defaults(func=cmd_research)

    skills = sub.add_parser("skills", help="Manage canonical skills")
    skill_sub = skills.add_subparsers(dest="skills_cmd", required=True)
    skill_sub.add_parser("list", help="List canonical skills").set_defaults(func=cmd_skills)
    skill_sub.add_parser("validate", help="Validate canonical skills").set_defaults(func=cmd_skills)
    skill_sync = skill_sub.add_parser("sync", help="Sync skills into adapter directories")
    skill_sync.add_argument("--target", action="append", help="Target skill directory; repeatable")
    skill_sync.set_defaults(func=cmd_skills)

    methods = sub.add_parser("methods", help="List and inspect research framework methods")
    method_sub = methods.add_subparsers(dest="methods_cmd", required=True)
    method_sub.add_parser("list", help="List research methods").set_defaults(func=cmd_methods)
    method_show = method_sub.add_parser("show", help="Show one research method")
    method_show.add_argument("method")
    method_show.set_defaults(func=cmd_methods)

    status = sub.add_parser("status", help="Show project status")
    status.add_argument("project", nargs="?", help="Project slug or path")
    status.set_defaults(func=cmd_status)

    review = sub.add_parser("review", help="Run Phase 3 review")
    review.add_argument("project", help="Project slug or path")
    review.add_argument("--model-review", action="store_true", help="Run independent model-based Phase 3 review")
    review.add_argument("--model", default="zenmux-anthropic/claude-opus-4-7", help="Model for --model-review")
    review.add_argument("--max-context-chars", type=int, default=650_000, help="Bounded context size for model review")
    review.add_argument("--dry-run", action="store_true")
    review.set_defaults(func=cmd_review)

    prompt = sub.add_parser("prompt", help="Print a Codex command prompt template")
    prompt.add_argument("command", help="Command name, e.g. dr-frame or frame")
    prompt.add_argument("argument", nargs="?", help="Replacement for $ARGUMENTS")
    prompt.set_defaults(func=cmd_prompt)

    glossary = sub.add_parser("glossary", help="Run glossary verification")
    glossary.add_argument("project", help="Project slug or path")
    glossary.add_argument("--workers", type=int, default=4)
    glossary.add_argument("--force", action="store_true")
    glossary.add_argument("--only")
    glossary.add_argument("--input")
    glossary.add_argument("--output")
    glossary.add_argument("--dry-run", action="store_true")
    glossary.set_defaults(func=cmd_glossary)

    finalize = sub.add_parser("finalize", help="Run Phase 4 deterministic pipeline")
    finalize.add_argument("project", help="Project slug or path")
    finalize.add_argument("--input", default="phase4/final_zh.md", help="Chinese Markdown source for default v0.20 finalization")
    finalize.add_argument("--legacy-translate", action="store_true", help="Use legacy final_en -> translate -> polish pipeline")
    finalize.add_argument("--polish", action="store_true", help="Run optional Chinese polish step before rendering")
    finalize.add_argument("--number-citations", action="store_true", help="Convert [src_xxx] citations to numeric references before rendering")
    finalize.add_argument("--numbered-output", default="phase4/final_zh_numbered.md", help="Output path for numeric citation Markdown")
    finalize.add_argument("--report-engine", choices=["reportlab", "quarto"], default=None)
    finalize.add_argument("--no-docx", action="store_true")
    finalize.add_argument("--no-pdf", action="store_true")
    finalize.add_argument("--translate-workers", type=int, default=0)
    finalize.add_argument("--glossary-workers", type=int, default=4)
    finalize.add_argument("--polish-workers", type=int, default=0)
    finalize.add_argument(
        "--glossary-mode",
        choices=["off", "low-confidence", "full"],
        default="low-confidence",
    )
    finalize.add_argument("--model-profile", help="Model profile name from configs/models.yaml")
    finalize.add_argument(
        "--model-override",
        action="append",
        default=[],
        metavar="ROLE=MODEL",
        help="Override one role model, repeatable",
    )
    finalize.add_argument("--dry-run", action="store_true")
    finalize.set_defaults(func=cmd_finalize)

    models = sub.add_parser("models", help="Resolve and print model profile")
    models.add_argument("--profile", help="Profile name from configs/models.yaml")
    models.add_argument("--list", action="store_true", help="List available profiles")
    models.add_argument(
        "--model-override",
        action="append",
        default=[],
        metavar="ROLE=MODEL",
        help="Override one role model, repeatable",
    )
    models.add_argument("--probe", action="store_true", help="Send tiny health checks to resolved role models")
    models.add_argument("--json", action="store_true", help="Emit JSON")
    models.set_defaults(func=cmd_models)

    sources = sub.add_parser("sources", help="Manage source snapshots and source registry")
    sources_sub = sources.add_subparsers(dest="sources_cmd", required=True)
    sources_cache = sources_sub.add_parser("cache", help="Cache important sources as local Markdown snapshots")
    sources_cache.add_argument("project", help="Project slug or path")
    sources_cache.add_argument("--sources", default="phase2/sources.jsonl", help="Source registry path relative to project")
    sources_cache.add_argument("--all", action="store_true", help="Cache all remote sources, not only important official/Tier 1 sources")
    sources_cache.add_argument("--limit", type=int, help="Maximum sources to cache in this run")
    sources_cache.add_argument("--force", action="store_true", help="Refetch even if cached_text_path already exists")
    sources_cache.add_argument("--dry-run", action="store_true")
    sources_cache.set_defaults(func=cmd_sources)

    apply_models = sub.add_parser("apply-models", help="Apply profile to agent files")
    apply_models.add_argument("--profile", required=True, help="Profile name from configs/models.yaml")
    apply_models.add_argument("--target", choices=["opencode", "codex", "both"], default="both")
    apply_models.add_argument(
        "--model-override",
        action="append",
        default=[],
        metavar="ROLE=MODEL",
        help="Override one role model, repeatable",
    )
    apply_models.add_argument("--dry-run", action="store_true")
    apply_models.set_defaults(func=cmd_apply_models)

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    return args.func(args)


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