import json

from fastapi import FastAPI, HTTPException, Response, status
from fastapi.responses import HTMLResponse

from .advisor import Advisor, AdvisorUnavailable, DiscussionNotFound
from .discussion import Brief, DiscussionSpec
from .domain import TaskSpec
from .persistence import InvalidTaskId, TaskNotFoundError
from .infra.git import IsolationError, check_writable, running_as
from .locking import TaskBusy
from .services import RepositoryNotAllowed
from .state_machine import InvalidTransition
from .web import INDEX_HTML


def create_app(orchestrator, advisor: Advisor | None = None, auth=None) -> FastAPI:
    app = FastAPI(title="Dual Agent Coding Orchestrator", version="0.1.0")

    def _advisor() -> Advisor:
        if advisor is None:
            raise HTTPException(501, "discussion is not enabled on this service")
        return advisor

    def _discussion(call):
        try:
            return call()
        except DiscussionNotFound as error:
            raise HTTPException(404, "discussion not found") from error
        except AdvisorUnavailable as error:
            raise HTTPException(409, str(error)) from error
        except ValueError as error:
            raise HTTPException(400, str(error)) from error

    @app.get("/discussions")
    def list_discussions() -> list[dict]:
        return [record.model_dump(mode="json") for record in _advisor().list()]

    @app.post("/discussions", status_code=status.HTTP_201_CREATED)
    def start_discussion(spec: DiscussionSpec):
        return _discussion(lambda: _advisor().start(spec))

    @app.get("/discussions/{discussion_id}")
    def get_discussion(discussion_id: str) -> dict:
        record = _discussion(lambda: _advisor().record(discussion_id))
        return {
            "record": record.model_dump(mode="json"),
            "messages": [m.model_dump(mode="json") for m in _advisor().messages(discussion_id)],
        }

    @app.post("/discussions/{discussion_id}/say")
    def say_to_discussion(discussion_id: str, message: dict) -> list[dict]:
        text = (message or {}).get("text", "")
        voices = (message or {}).get("voices") or None
        answers = _discussion(lambda: _advisor().say(discussion_id, text, voices))
        return [answer.model_dump(mode="json") for answer in answers]

    @app.post("/discussions/{discussion_id}/reply")
    def reply_in_discussion(discussion_id: str, message: dict | None = None) -> list[dict]:
        """Ask for a response to what is already there, without adding a user turn."""
        voices = (message or {}).get("voices") or None
        answers = _discussion(lambda: _advisor().reply(discussion_id, voices))
        return [answer.model_dump(mode="json") for answer in answers]

    @app.post("/discussions/{discussion_id}/brief")
    def draft_brief(discussion_id: str) -> dict:
        return _discussion(lambda: _advisor().draft_brief(discussion_id)).model_dump(mode="json")

    @app.put("/discussions/{discussion_id}/brief")
    def edit_brief(discussion_id: str, brief: Brief) -> dict:
        return _discussion(lambda: _advisor().set_brief(discussion_id, brief)).model_dump(mode="json")

    @app.post("/discussions/{discussion_id}/task", status_code=status.HTTP_201_CREATED)
    def discussion_to_task(discussion_id: str):
        try:
            return _discussion(lambda: _advisor().to_task(discussion_id))
        except RepositoryNotAllowed as error:
            raise HTTPException(403, str(error)) from error
        except IsolationError as error:
            raise HTTPException(400, str(error)) from error

    @app.get("/", response_class=HTMLResponse, include_in_schema=False)
    def index() -> str:
        return INDEX_HTML

    @app.get("/tasks")
    def list_tasks() -> list[dict]:
        root = orchestrator.store.root / "tasks"
        tasks = [orchestrator.store.load(d.name) for d in sorted(root.iterdir()) if d.is_dir()] if root.is_dir() else []
        return [
            {"task_id": t.task_id, "state": t.state.value, "goal": t.goal, "cycle": t.cycle}
            for t in tasks
        ]

    def _adapter_readiness() -> dict:
        adapters = {}
        for role in ("planner", "implementer"):
            adapter = getattr(orchestrator, role, None)
            probe = getattr(adapter, "probe", None)
            if probe is None:
                adapters[role] = {"kind": type(adapter).__name__, "ready": True, "missing": []}
                continue
            try:
                capability = probe()
                adapters[role] = {
                    "kind": type(adapter).__name__,
                    "ready": capability.compatible,
                    "missing": list(capability.missing_features),
                }
            except Exception as error:  # noqa: BLE001 - a probe must never take the app down
                adapters[role] = {"kind": type(adapter).__name__, "ready": False, "missing": [str(error)]}
        return adapters

    def _auth():
        if auth is None:
            raise HTTPException(501, "sign-in is not available on this service")
        return auth

    @app.get("/auth")
    def auth_status() -> dict:
        """Whether each provider is signed in for the account running the service."""
        return _auth().status_all()

    @app.post("/auth/{provider}/login")
    def auth_login(provider: str) -> dict:
        """Start the CLI's own device-code sign-in and return what to open.

        The operator completes it in their browser; no secret passes through here.
        """
        try:
            return _auth().begin(provider)
        except ValueError as error:
            raise HTTPException(400, str(error)) from error
        except OSError as error:
            raise HTTPException(409, f"无法启动登录：{error}") from error

    @app.post("/auth/{provider}/login/code")
    def auth_submit_code(provider: str, payload: dict) -> dict:
        """Send a browser callback code to a provider CLI that is awaiting it."""
        try:
            submitted = _auth().submit_code(provider, str(payload.get("code", "")))
        except ValueError as error:
            raise HTTPException(400, str(error)) from error
        if not submitted:
            raise HTTPException(409, "没有正在等待授权码的登录进程")
        return {"submitted": True}
    @app.get("/auth/{provider}/login")
    def auth_progress(provider: str) -> dict:
        return _auth().progress(provider)

    @app.delete("/auth/{provider}/login")
    def auth_cancel(provider: str) -> dict:
        return {"cancelled": _auth().cancel(provider)}

    @app.get("/agents")
    def agents() -> dict:
        """What the UI needs to offer model and effort choices."""
        def describe(role: str) -> dict:
            adapter = getattr(orchestrator, role, None)
            return {
                "kind": type(adapter).__name__,
                "model": getattr(adapter, "model", "") or "",
                "effort": getattr(adapter, "effort", "") or "",
            }

        return {
            "planner": describe("planner"),
            "implementer": describe("implementer"),
            # Effort levels each CLI accepts. Codex takes these through
            # `-c model_reasoning_effort`, Claude through `--effort`.
            "efforts": ["", "low", "medium", "high", "xhigh", "max"],
            "tunable": orchestrator.adapter_factory is not None,
        }

    @app.get("/health")
    def health() -> dict[str, str]:
        """Liveness only. Never probes a provider, so it stays fast and always answers."""
        return {"status": "ok"}

    @app.get("/ready")
    def ready(response: Response) -> dict:
        """Readiness: providers probed, runtime writable. 503 when the service cannot work."""
        adapters = _adapter_readiness()
        runtime_ok, runtime_error = True, None
        try:
            root = orchestrator.store.root
            root.mkdir(parents=True, exist_ok=True)
            probe_file = root / ".readiness"
            probe_file.write_text("ok", encoding="utf-8")
            probe_file.unlink()
        except OSError as error:
            runtime_ok, runtime_error = False, str(error)
        roots = []
        for root in getattr(orchestrator, "allowed_repo_roots", []) or []:
            try:
                check_writable(root)
                roots.append({"path": str(root), "writable": True, "error": None})
            except IsolationError as error:
                roots.append({"path": str(root), "writable": False, "error": str(error)})
        is_ready = (
            runtime_ok
            and all(entry["ready"] for entry in adapters.values())
            and all(entry["writable"] for entry in roots)
        )
        if not is_ready:
            response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
        return {
            "ready": is_ready,
            "running_as": running_as(),
            "adapters": adapters,
            "runtime": {"ready": runtime_ok, "error": runtime_error},
            "repo_roots": roots,
        }

    @app.post("/tasks", status_code=status.HTTP_201_CREATED)
    def create_task(spec: TaskSpec):
        try:
            return orchestrator.create_task(spec)
        except RepositoryNotAllowed as error:
            raise HTTPException(403, str(error)) from error
        except IsolationError as error:
            # An unusable target path is the caller's problem, not a server fault: say so
            # in the response instead of making them read the service log.
            raise HTTPException(400, str(error)) from error

    @app.post("/recover")
    def recover_all() -> list:
        return orchestrator.recover_all()

    @app.get("/tasks/{task_id}")
    def get_task(task_id: str):
        try:
            return orchestrator.get_task(task_id)
        except (TaskNotFoundError, InvalidTaskId) as error:
            raise HTTPException(404, "task not found") from error

    def action(task_id: str, name: str):
        try:
            return getattr(orchestrator, name)(task_id)
        except (TaskNotFoundError, InvalidTaskId) as error:
            raise HTTPException(404, "task not found") from error
        except InvalidTransition as error:
            raise HTTPException(409, str(error)) from error
        except TaskBusy as error:
            raise HTTPException(409, str(error)) from error

    @app.get("/tasks/{task_id}/conversation")
    def conversation(task_id: str) -> list[dict]:
        if not orchestrator.store.task_dir(task_id).is_dir():
            raise HTTPException(404, "task not found")
        return [turn.model_dump(mode="json") for turn in orchestrator.conversation(task_id).all()]

    @app.post("/tasks/{task_id}/say")
    def say(task_id: str, message: dict):
        text = (message or {}).get("text", "")
        try:
            return orchestrator.say(task_id, text)
        except ValueError as error:
            raise HTTPException(400, str(error)) from error
        except TaskNotFoundError as error:
            raise HTTPException(404, "task not found") from error

    @app.get("/tasks/{task_id}/detail")
    def detail(task_id: str) -> dict:
        directory = orchestrator.store.task_dir(task_id)
        if not directory.is_dir():
            raise HTTPException(404, "task not found")
        events_path = directory / "events.jsonl"
        events = [json.loads(line) for line in events_path.read_text(encoding="utf-8").splitlines() if line] if events_path.is_file() else []
        artifacts = {
            path.name: json.loads(path.read_text(encoding="utf-8"))
            for path in sorted(directory.glob("*.json"))
            if path.name != "state.json"
        }
        summary = directory / "summary.md"
        return {
            "events": events,
            "artifacts": artifacts,
            "summary": summary.read_text(encoding="utf-8") if summary.is_file() else "",
        }

    @app.post("/tasks/{task_id}/advance")
    def advance(task_id: str):
        return action(task_id, "advance")

    @app.get("/tasks/{task_id}/logs")
    def logs(task_id: str, tail: int = 200) -> dict:
        """Live tail of the agent and command logs, newest file first."""
        directory = orchestrator.store.task_dir(task_id) / "logs"
        if not directory.is_dir():
            raise HTTPException(404, "no logs yet")
        files = sorted(directory.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
        return {
            "files": [
                {
                    "name": path.name,
                    "tail": "".join(
                        path.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)[-tail:]
                    ),
                }
                for path in files[:6]
            ]
        }

    @app.get("/memory")
    def memory(q: str = "", limit: int = 20) -> list:
        entries = orchestrator.memory.search(q, limit) if q else orchestrator.memory.all()[-limit:]
        return [entry.model_dump(mode="json") for entry in entries]

    @app.get("/tasks/{task_id}/diff")
    def diff(task_id: str) -> dict:
        """Worktree changes since the recorded baseline, for review in the UI."""
        import subprocess

        task = get_task(task_id)
        worktree = task.worktree_path or task.repo_path
        argv = ["git", "-C", str(worktree), "diff", "--stat", "-p"]
        if task.baseline_commit:
            argv.append(task.baseline_commit)
        result = subprocess.run(argv, capture_output=True, text=True, errors="replace")
        if result.returncode:
            raise HTTPException(409, result.stderr.strip() or "diff failed")
        return {"diff": result.stdout, "baseline": task.baseline_commit}

    @app.post("/tasks/{task_id}/approve")
    def approve(task_id: str):
        return action(task_id, "approve")

    @app.post("/tasks/{task_id}/run")
    def run_to_completion(task_id: str):
        return action(task_id, "run_to_completion")

    @app.post("/tasks/{task_id}/cancel")
    def cancel(task_id: str):
        return action(task_id, "cancel")

    @app.post("/tasks/{task_id}/resume")
    def resume(task_id: str):
        return action(task_id, "recover")

    return app

