import hashlib
import json
import os
import secrets
import threading
import time
from collections import deque

from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, JSONResponse

from .advisor import Advisor, AdvisorUnavailable, DiscussionNotFound
from .discussion import Brief, DiscussionSpec
from .domain import Agents, ModelCatalog, TaskSpec, TaskState
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

# ── PWA assets ────────────────────────────────────────────────────────


def _make_icon_png(size: int) -> bytes:
    """Generate a minimal solid-colour PNG at the given square pixel size.

    Uses only the Python standard library (struct + zlib).  The icon is a flat
    blue square matching the brand colour #2c6e8e — good enough for Home Screen
    icon purposes without pulling in Pillow or cairosvg.
    """
    import struct
    import zlib

    r, g, b = 44, 110, 142  # #2c6e8e
    row = bytes([0] + [r, g, b] * size)  # filter byte 0 (None) + RGB pixels
    raw = row * size
    compressed = zlib.compress(raw, 9)

    def _chunk(tag: bytes, data: bytes) -> bytes:
        crc = zlib.crc32(tag + data) & 0xFFFFFFFF
        return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc)

    ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
    return (
        b"\x89PNG\r\n\x1a\n"
        + _chunk(b"IHDR", ihdr)
        + _chunk(b"IDAT", compressed)
        + _chunk(b"IEND", b"")
    )


ICON_192_PNG: bytes = _make_icon_png(192)
ICON_512_PNG: bytes = _make_icon_png(512)

MANIFEST = {
    "name": "Dual Agent Orchestrator",
    "short_name": "DualAgent",
    "description": "AI 编码任务编排控制台",
    "start_url": "/",
    "display": "standalone",
    "orientation": "any",
    "theme_color": "#2c6e8e",
    "background_color": "#f7f8fa",
    "icons": [
        {"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"},
        {"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable"},
        # Raster icons: Android Chrome install criteria and iOS add-to-home-screen.
        {"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
        {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"},
    ],
}

ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
  <rect width="192" height="192" rx="32" fill="#2c6e8e"/>
  <text x="96" y="128" text-anchor="middle" font-family="system-ui,sans-serif"
        font-size="88" font-weight="700" fill="#ffffff">DA</text>
</svg>"""

# Network-first service worker: never caches API or task state.
SERVICE_WORKER_JS = r"""
const CACHE = 'da-shell-v3';
const SHELL = ['/', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png'];

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)).catch(() => {}));
  self.skipWaiting();
});

self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    ).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', e => {
  // Network-first for everything. Never cache API or task state.
  e.respondWith(
    fetch(e.request).then(r => {
      // Only cache shell resources on success
      if (r.ok && SHELL.includes(new URL(e.request.url).pathname)) {
        caches.open(CACHE).then(c => c.put(e.request, r.clone())).catch(() => {});
      }
      return r;
    }).catch(() => {
      if (e.request.mode === 'navigate') {
        return caches.match('/').then(r =>
          r || new Response('<h1>离线</h1><p>请连接网络后刷新页面。</p>',
            {headers: {'Content-Type': 'text/html; charset=utf-8'}})
        );
      }
      return new Response(JSON.stringify({detail: 'offline'}), {
        status: 503, headers: {'Content-Type': 'application/json'}
      });
    })
  );
});
"""

# Login bridge page: served for unauthenticated GET / so the token is delivered
# via POST /cookie (Authorization header) instead of appearing in request URLs.
LOGIN_BRIDGE_HTML = """<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dual Agent · 认证</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
     min-height:100vh;margin:0;background:#f7f8fa;color:#1c2230}
.box{background:#fff;border-radius:12px;padding:24px;max-width:360px;width:90%;
     box-shadow:0 2px 12px rgba(0,0,0,.1)}
h1{font-size:18px;margin:0 0 8px}
p{color:#5b6575;font-size:13px;margin:0 0 16px;line-height:1.6}
input{width:100%;padding:10px;border:1px solid #dde1e8;border-radius:8px;font:inherit;
      box-sizing:border-box;margin-bottom:12px}
button{width:100%;padding:12px;background:#2c6e8e;color:#fff;border:none;border-radius:8px;
       font:inherit;font-weight:600;cursor:pointer;min-height:44px}
.err{color:#b03a2e;font-size:13px;margin-top:8px;display:none}
</style>
</head>
<body>
<div class="box">
<h1>Dual Agent</h1>
<p>请输入访问令牌，或使用带 <code>#token=&lt;令牌&gt;</code> 的链接直接访问。</p>
<input id="tok" type="password" placeholder="访问令牌" autocomplete="current-password" autofocus>
<button onclick="submit()">进入</button>
<div id="err" class="err">令牌错误，请重试。</div>
</div>
<script>
async function tryToken(t){
  if(!t) return false;
  const r=await fetch('/cookie',{method:'POST',headers:{'Authorization':'Bearer '+t}});
  return r.ok;
}
async function submit(){
  const t=document.getElementById('tok').value.trim();
  if(!t) return;
  const ok=await tryToken(t);
  if(ok){history.replaceState(null,'','/');location.reload();}
  else document.getElementById('err').style.display='block';
}
document.getElementById('tok').addEventListener('keydown',e=>{if(e.key==='Enter')submit();});
(async()=>{
  const h=location.hash;
  if(h.startsWith('#token=')){
    const t=decodeURIComponent(h.slice(7));
    history.replaceState(null,'','/');
    const ok=await tryToken(t);
    if(ok) location.reload();
    else document.getElementById('err').style.display='block';
  }
})();
</script>
</body>
</html>"""

# ── Idempotency store ─────────────────────────────────────────────────

_PENDING = object()  # sentinel: a request for this key is currently in-flight

# Fallback TTL only for pending files that contain no parseable PID (e.g. empty
# files written by an older deployment before this fix).  Long-running operations
# (advance, run_to_completion) can exceed 60 s, so this guard is NOT used as the
# primary staleness check — PID liveness is used instead.
_PENDING_STALE_SECS = 300


def _pid_is_running(pid: int) -> bool:
    """Return True if the process with the given PID is still running."""
    try:
        os.kill(pid, 0)  # signal 0: check existence, don't send a signal
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True  # process exists but we can't signal it — treat as running


class _IdempotencyStore:
    """Cache last result per X-Request-Id so a network retry doesn't re-execute.

    claim() atomically checks and reserves a key in one lock acquisition,
    preventing the TOCTOU window where two concurrent requests with the same
    X-Request-Id could both see a cache miss and both execute the operation.

    SINGLE-PROCESS LIMITATION: This store is in-memory only. Idempotency
    guarantees hold within one server process (single uvicorn worker) and
    within the TTL window. After a server restart, or across multiple workers,
    the cache is empty and a client retry with the same X-Request-Id will re-
    execute the action. The task state machine (state checks + task lock)
    provides the safety net: a re-executed transition either succeeds (state
    is valid) or raises 409 (InvalidTransition / TaskBusy). This is acceptable
    for the intended single-worker personal-project deployment.
    """

    TTL = 300  # seconds; covers typical mobile network timeout-and-retry windows

    def __init__(self) -> None:
        self._store: dict[str, dict] = {}
        self._lock = threading.Lock()

    def claim(self, key: str):
        """Atomically check and claim a request key.

        Returns:
          None      – key was unclaimed; caller now owns it (in-flight marker set)
          _PENDING  – another call with this key is currently executing
          <value>   – a prior call completed; return this cached result
        """
        with self._lock:
            entry = self._store.get(key)
            now = time.monotonic()
            if entry is None or now - entry["at"] > self.TTL:
                self._store[key] = {"in_flight": True, "at": now}
                return None
            if entry.get("in_flight"):
                return _PENDING
            return entry["value"]

    def set(self, key: str, value) -> None:
        with self._lock:
            self._store[key] = {"value": value, "at": time.monotonic()}
            cutoff = time.monotonic() - self.TTL
            stale = [k for k, v in self._store.items() if v["at"] < cutoff and not v.get("in_flight")]
            for k in stale:
                del self._store[k]

    def discard(self, key: str) -> None:
        """Release a claimed-but-failed key so the client can retry."""
        with self._lock:
            entry = self._store.get(key)
            if entry and entry.get("in_flight"):
                del self._store[key]


# ── Persistent task idempotency helpers ───────────────────────────────

def _safe_idem_key(req_id: str) -> str:
    return hashlib.sha256(req_id.encode()).hexdigest()[:16]




def _persist_task_idem(store, task_id: str, req_id: str, action: str, result) -> None:
    """Write an idempotency record to {task_dir}/idem/ so it survives server restarts.
    Also removes the corresponding .pending marker written by _claim_task_idem_pending."""
    try:
        idem_dir = store.task_dir(task_id) / "idem"
        idem_dir.mkdir(parents=True, exist_ok=True)
        data = result if isinstance(result, dict) else result.model_dump(mode="json")
        key = _safe_idem_key(req_id)
        path = idem_dir / f"{action}_{key}.json"
        tmp = path.with_suffix(".tmp")
        tmp.write_text(json.dumps({"req_id": req_id, "action": action, "result": data}), encoding="utf-8")
        tmp.replace(path)
        # Release the cross-process pending claim now that the final record exists
        (idem_dir / f"{action}_{key}.pending").unlink(missing_ok=True)
    except Exception:  # noqa: BLE001 — disk error must not break the response
        pass


def _is_pending_stale(pending_path) -> bool:
    """Return True if the .pending file belongs to a dead process (safe to reclaim).

    Primary check: read the PID from the file and test whether that process is
    still running.  Only operations that are still in-progress have a live PID;
    the file is stale iff the process has exited or the PID cannot be parsed.

    Fallback: if the file is empty or contains a non-PID value (e.g. written by
    an older deployment), fall back to the mtime guard (_PENDING_STALE_SECS).
    """
    try:
        content = pending_path.read_text(encoding="utf-8").strip()
        if content:
            pid = int(content)
            return not _pid_is_running(pid)
    except (ValueError, TypeError):
        pass
    except OSError:
        return True  # Can't read the file at all — treat as stale
    # Empty file or non-integer content: fall back to mtime guard
    try:
        return (time.time() - pending_path.stat().st_mtime) >= _PENDING_STALE_SECS
    except OSError:
        return True


def _load_task_idem(store, task_id: str, req_id: str, action: str):
    """Return a previously persisted result dict, _PENDING if another process is executing,
    or None if not found / corrupt."""
    try:
        key = _safe_idem_key(req_id)
        idem_dir = store.task_dir(task_id) / "idem"
        path = idem_dir / f"{action}_{key}.json"
        if path.is_file():
            record = json.loads(path.read_text(encoding="utf-8"))
            if record.get("req_id") == req_id and record.get("action") == action:
                return record["result"]
        # No completed record — check for a live pending marker from another process.
        pending = idem_dir / f"{action}_{key}.pending"
        if pending.is_file():
            if not _is_pending_stale(pending):
                return _PENDING
            # Stale marker from a dead process — delete eagerly so the next
            # _claim_task_idem_pending call can create a fresh one.
            pending.unlink(missing_ok=True)
    except Exception:  # noqa: BLE001
        pass
    return None


def _claim_task_idem_pending(store, task_id: str, req_id: str, action: str) -> bool:
    """Atomically claim an idempotency slot using exclusive file creation.

    Returns True if we own the slot (caller should execute then call _persist_task_idem).
    Returns False if another process already holds the slot (caller should return 409).
    O_CREAT|O_EXCL is atomic on POSIX: exactly one opener wins the race.

    The current PID is written into the file so that _load_task_idem can determine
    whether a pending marker belongs to a live process rather than relying on a fixed
    time threshold.  This means long-running operations (advance, run_to_completion)
    correctly hold the idempotency slot for as long as the owning process runs.
    """
    try:
        idem_dir = store.task_dir(task_id) / "idem"
        idem_dir.mkdir(parents=True, exist_ok=True)
        pending = idem_dir / f"{action}_{_safe_idem_key(req_id)}.pending"
        pid_bytes = str(os.getpid()).encode()
        for _attempt in range(2):
            try:
                fd = os.open(str(pending), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
                os.write(fd, pid_bytes)
                os.close(fd)
                return True
            except FileExistsError:
                # Check if the existing marker belongs to a dead process.
                if _is_pending_stale(pending):
                    pending.unlink(missing_ok=True)
                    continue
                return False  # Live process holds the slot
        return False
    except Exception:  # noqa: BLE001 — disk error: proceed without cross-process guard
        return True


def _discard_task_idem_pending(store, task_id: str, req_id: str, action: str) -> None:
    """Remove the pending marker so the next request can claim the slot."""
    try:
        pending = store.task_dir(task_id) / "idem" / f"{action}_{_safe_idem_key(req_id)}.pending"
        pending.unlink(missing_ok=True)
    except Exception:  # noqa: BLE001
        pass


# ── Token auth helpers ─────────────────────────────────────────────────

def _make_auth_middleware(token: str | None):
    """Return a middleware function, or None when no token is configured."""
    if not token:
        return None

    async def _auth(request: Request, call_next):
        # Health, cookie-exchange, and static PWA assets never require auth.
        # PWA assets are exempted so the service worker can cache them during
        # install even if the session cookie is not present in that context.
        if request.url.path in ("/health", "/cookie", "/sw.js", "/manifest.webmanifest", "/icon.svg", "/icon-192.png", "/icon-512.png"):
            return await call_next(request)

        # Accept via Authorization header
        auth_header = request.headers.get("Authorization", "")
        if auth_header.startswith("Bearer ") and secrets.compare_digest(
            auth_header[7:], token
        ):
            return await call_next(request)

        # Accept via cookie (set by the JS after /cookie exchange)
        cookie_token = request.cookies.get("da_token", "")
        if cookie_token and secrets.compare_digest(cookie_token, token):
            return await call_next(request)

        # Unauthenticated GET / → login bridge page (token goes in POST /cookie, not URL)
        if request.method == "GET" and request.url.path == "/":
            return HTMLResponse(LOGIN_BRIDGE_HTML)

        # Return 401 for everything else – not logged for security
        return Response(
            status_code=401,
            content='{"detail":"令牌缺失或错误，请访问页面并输入令牌，或使用 Authorization: Bearer <token>"}',
            media_type="application/json",
            headers={"WWW-Authenticate": "Bearer"},
        )

    return _auth


# ── App factory ───────────────────────────────────────────────────────

def create_app(
    orchestrator,
    advisor: "Advisor | None" = None,
    auth=None,
    token: str | None = None,
) -> FastAPI:
    app = FastAPI(title="Dual Agent Coding Orchestrator", version="0.1.0")
    _idempotency = _IdempotencyStore()

    # Optional bearer-token middleware
    _auth_fn = _make_auth_middleware(token)
    if _auth_fn:
        from starlette.middleware.base import BaseHTTPMiddleware
        app.add_middleware(BaseHTTPMiddleware, dispatch=_auth_fn)

    # ── PWA ──────────────────────────────────────────────────────────

    @app.get("/manifest.webmanifest", include_in_schema=False)
    def manifest():
        return JSONResponse(
            MANIFEST,
            headers={"Cache-Control": "public, max-age=86400"},
        )

    @app.get("/sw.js", include_in_schema=False)
    def service_worker():
        return Response(
            SERVICE_WORKER_JS,
            media_type="application/javascript",
            headers={"Cache-Control": "no-cache, no-store"},
        )

    @app.get("/icon.svg", include_in_schema=False)
    def icon():
        return Response(
            ICON_SVG,
            media_type="image/svg+xml",
            headers={"Cache-Control": "public, max-age=604800"},
        )

    @app.get("/icon-192.png", include_in_schema=False)
    def icon_192():
        return Response(
            ICON_192_PNG,
            media_type="image/png",
            headers={"Cache-Control": "public, max-age=604800"},
        )

    @app.get("/icon-512.png", include_in_schema=False)
    def icon_512():
        return Response(
            ICON_512_PNG,
            media_type="image/png",
            headers={"Cache-Control": "public, max-age=604800"},
        )

    @app.post("/cookie", include_in_schema=False)
    def set_auth_cookie(request: Request):
        """Exchange a bearer token for a session cookie.

        The token travels in the Authorization header (never in the URL) so it
        does not appear in server access logs, browser history, or Referer headers.
        The middleware exempts this endpoint from auth checks so it is always reachable.

        Security note: over plain HTTP (no TLS termination) the token and cookie
        travel in cleartext on the network. Set up an HTTPS reverse proxy and the
        X-Forwarded-Proto: https header to enable the Secure cookie attribute and
        protect the session from network eavesdropping.
        """
        if not token:
            return Response(status_code=204)
        auth_header = request.headers.get("Authorization", "")
        if auth_header.startswith("Bearer ") and secrets.compare_digest(
            auth_header[7:], token
        ):
            resp = Response(status_code=204)
            # Set Secure flag only when the request arrived via HTTPS (reverse proxy
            # sets X-Forwarded-Proto: https). Over plain HTTP the flag is omitted so
            # the browser will actually store the cookie, but the token travels in
            # cleartext — see the LAN startup warning.
            _secure = request.headers.get("X-Forwarded-Proto", "").lower() == "https"
            resp.set_cookie(
                "da_token", token, httponly=True, samesite="strict", secure=_secure
            )
            return resp
        return Response(
            status_code=401,
            content='{"detail":"令牌错误"}',
            media_type="application/json",
            headers={"WWW-Authenticate": "Bearer"},
        )

    # ── Main UI ───────────────────────────────────────────────────────

    @app.get("/", response_class=HTMLResponse, include_in_schema=False)
    def index() -> str:
        return INDEX_HTML

    # ── Discussions ───────────────────────────────────────────────────

    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

    def _disc_idempotent(request: Request, prefix: str, body, action):
        """Run action() idempotently; cache key includes body hash to prevent
        stale hits when the same X-Request-Id is reused with a different payload."""
        req_id = request.headers.get("X-Request-Id", "")
        cache_key = None
        if req_id:
            body_hash = hashlib.sha256(
                json.dumps(body, sort_keys=True, default=str).encode()
            ).hexdigest()[:16]
            cache_key = f"{prefix}:{req_id}:{body_hash}"
            hit = _idempotency.claim(cache_key)
            if hit is _PENDING:
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if hit is not None:
                return hit
        try:
            result = action()
        except Exception:
            if cache_key:
                _idempotency.discard(cache_key)
            raise
        if cache_key:
            _idempotency.set(cache_key, result)
        return result

    @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(request: Request, spec: DiscussionSpec):
        req_id = request.headers.get("X-Request-Id", "")
        cache_key = ""
        if req_id:
            body_hash = hashlib.sha256(
                json.dumps(spec.model_dump(mode="json"), sort_keys=True, default=str).encode()
            ).hexdigest()[:16]
            cache_key = f"start_discussion:{req_id}:{body_hash}"
        if cache_key:
            hit = _idempotency.claim(cache_key)
            if hit is _PENDING:
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if hit is not None:
                return hit
        try:
            result = _discussion(lambda: _advisor().start(spec))
        except Exception:
            if cache_key:
                _idempotency.discard(cache_key)
            raise
        if cache_key:
            _idempotency.set(cache_key, result)
        return result

    @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(request: Request, discussion_id: str, message: dict) -> list[dict]:
        text = (message or {}).get("text", "")
        voices = (message or {}).get("voices") or None
        return _disc_idempotent(
            request, f"say:{discussion_id}", message,
            lambda: [a.model_dump(mode="json") for a in
                     _discussion(lambda: _advisor().say(discussion_id, text, voices))],
        )

    @app.post("/discussions/{discussion_id}/reply")
    def reply_in_discussion(request: Request, 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
        return _disc_idempotent(
            request, f"reply:{discussion_id}", message,
            lambda: [a.model_dump(mode="json") for a in
                     _discussion(lambda: _advisor().reply(discussion_id, voices))],
        )

    @app.post("/discussions/{discussion_id}/brief")
    def draft_brief(request: Request, discussion_id: str) -> dict:
        return _disc_idempotent(
            request, f"brief:{discussion_id}", None,
            lambda: _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(request: Request, discussion_id: str):
        req_id = request.headers.get("X-Request-Id", "")
        cache_key = f"to_task:{discussion_id}:{req_id}" if req_id else ""
        if cache_key:
            hit = _idempotency.claim(cache_key)
            if hit is _PENDING:
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if hit is not None:
                return hit
        try:
            result = _discussion(lambda: _advisor().to_task(discussion_id))
        except RepositoryNotAllowed as error:
            if cache_key:
                _idempotency.discard(cache_key)
            raise HTTPException(403, str(error)) from error
        except IsolationError as error:
            if cache_key:
                _idempotency.discard(cache_key)
            raise HTTPException(400, str(error)) from error
        except Exception:
            if cache_key:
                _idempotency.discard(cache_key)
            raise
        if cache_key:
            _idempotency.set(cache_key, result)
        return result

    # ── Tasks ─────────────────────────────────────────────────────────

    @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
        ]

    @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:
            raise HTTPException(400, str(error)) from error
        except ValueError as error:
            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

    # ── Idempotent task actions ───────────────────────────────────────

    def _idempotent_action(request: Request, task_id: str, name: str):
        """Execute a state-changing action with idempotency protection.

        A client that retries after a network timeout reuses X-Request-Id.
        claim() atomically reserves the key so concurrent requests with the
        same ID cannot both execute the operation.
        """
        req_id = request.headers.get("X-Request-Id", "")
        cache_key = f"{name}:{task_id}:{req_id}" if req_id else ""
        _claimed_pending = False

        if cache_key:
            # L1: in-memory (fast path; within one process)
            hit = _idempotency.claim(cache_key)
            if hit is _PENDING:
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if hit is not None:
                return hit
            # L2: disk (survives restart; covers multi-worker retries)
            disk = _load_task_idem(orchestrator.store, task_id, req_id, name)
            if disk is _PENDING:
                _idempotency.discard(cache_key)
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if disk is not None:
                _idempotency.set(cache_key, disk)  # warm L1 for subsequent in-process retries
                return disk
            # Cross-process atomic claim: exactly one process may proceed
            if not _claim_task_idem_pending(orchestrator.store, task_id, req_id, name):
                _idempotency.discard(cache_key)
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            _claimed_pending = True

        try:
            result = getattr(orchestrator, name)(task_id)
        except (TaskNotFoundError, InvalidTaskId) as error:
            if cache_key:
                _idempotency.discard(cache_key)
                if _claimed_pending:
                    _discard_task_idem_pending(orchestrator.store, task_id, req_id, name)
            raise HTTPException(404, "task not found") from error
        except InvalidTransition as error:
            if cache_key:
                _idempotency.discard(cache_key)
                if _claimed_pending:
                    _discard_task_idem_pending(orchestrator.store, task_id, req_id, name)
            raise HTTPException(409, str(error)) from error
        except TaskBusy as error:
            if cache_key:
                _idempotency.discard(cache_key)
                if _claimed_pending:
                    _discard_task_idem_pending(orchestrator.store, task_id, req_id, name)
            raise HTTPException(409, str(error)) from error
        except Exception:
            if cache_key:
                _idempotency.discard(cache_key)
                if _claimed_pending:
                    _discard_task_idem_pending(orchestrator.store, task_id, req_id, name)
            raise

        if cache_key:
            _idempotency.set(cache_key, result)
            _persist_task_idem(orchestrator.store, task_id, req_id, name, result)
            # _persist_task_idem cleans up the .pending marker on success
        return result

    @app.get("/tasks/{task_id}/conversation")
    def conversation(task_id: str) -> list[dict]:
        try:
            directory = orchestrator.store.task_dir(task_id)
        except InvalidTaskId as error:
            raise HTTPException(404, "task not found") from error
        if not directory.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(request: Request, task_id: str, message: dict):
        text = (message or {}).get("text", "")
        req_id = request.headers.get("X-Request-Id", "")
        body_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
        cache_key = f"task_say:{task_id}:{req_id}:{body_hash}" if req_id else ""
        if cache_key:
            hit = _idempotency.claim(cache_key)
            if hit is _PENDING:
                raise HTTPException(409, "concurrent request in progress; retry shortly")
            if hit is not None:
                return hit
        try:
            result = orchestrator.say(task_id, text)
        except ValueError as error:
            if cache_key:
                _idempotency.discard(cache_key)
            raise HTTPException(400, str(error)) from error
        except TaskNotFoundError as error:
            if cache_key:
                _idempotency.discard(cache_key)
            raise HTTPException(404, "task not found") from error
        except Exception:
            if cache_key:
                _idempotency.discard(cache_key)
            raise
        if cache_key:
            _idempotency.set(cache_key, result)
        return result

    @app.get("/tasks/{task_id}/detail")
    def detail(task_id: str) -> dict:
        try:
            directory = orchestrator.store.task_dir(task_id)
        except InvalidTaskId as error:
            raise HTTPException(404, "task not found") from error
        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(request: Request, task_id: str):
        return _idempotent_action(request, task_id, "advance")

    @app.post("/tasks/{task_id}/approve")
    def approve(request: Request, task_id: str):
        return _idempotent_action(request, task_id, "approve")

    @app.post("/tasks/{task_id}/run")
    def run_to_completion(request: Request, task_id: str):
        return _idempotent_action(request, task_id, "run_to_completion")

    @app.post("/tasks/{task_id}/cancel")
    def cancel(request: Request, task_id: str):
        return _idempotent_action(request, task_id, "cancel")

    @app.post("/tasks/{task_id}/resume")
    def resume(request: Request, task_id: str):
        try:
            state = orchestrator.get_task(task_id).state
        except (TaskNotFoundError, InvalidTaskId) as error:
            raise HTTPException(404, "task not found") from error
        if state is TaskState.WAITING_CONTINUE:
            resumed = _idempotent_action(request, task_id, "resume")
        elif state in {TaskState.NEEDS_HUMAN, TaskState.DONE}:
            resumed = _idempotent_action(request, task_id, "recover")
        else:
            raise HTTPException(409, f"task {task_id} is not resumable from {state.value}")
        if resumed.state in {TaskState.DONE, TaskState.NEEDS_HUMAN, TaskState.WAITING_CONTINUE}:
            return resumed
        return _idempotent_action(request, task_id, "run_to_completion")

    @app.get("/tasks/{task_id}/logs")
    def logs(task_id: str, tail: int = 200, offset: int = 0) -> dict:
        """Live tail of the agent and command logs, newest file first.

        offset: skip this many lines from the end before taking tail lines.
        Enables pagination: offset=0 → last tail lines; offset=N → lines before the last N.
        """
        tail = min(max(tail, 1), 2000)
        offset = min(max(offset, 0), 10000)
        try:
            directory = orchestrator.store.task_dir(task_id) / "logs"
        except InvalidTaskId as error:
            raise HTTPException(404, "no logs yet") from error
        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)
        result = []
        for path in files[:6]:
            lines = deque(maxlen=tail + offset)
            total = 0
            with path.open(encoding="utf-8", errors="replace") as handle:
                for line in handle:
                    total += 1
                    lines.append(line)
            end = max(0, total - offset)
            start = max(0, end - tail)
            result.append({
                "name": path.name,
                "tail": "".join(list(lines)[max(0, start - max(0, total - len(lines))):end - max(0, total - len(lines))]),
                "total_lines": total,
            })
        return {"files": result}

    @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.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]

    # ── Auth ──────────────────────────────────────────────────────────

    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:
        return _auth().status_all()

    @app.post("/auth/{provider}/login")
    def auth_login(provider: str) -> dict:
        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:
        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, "No sign-in process is waiting for an authorization code")
        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)}

    # ── Agents ────────────────────────────────────────────────────────

    @app.get("/agents")
    def agents() -> dict:
        config: Agents = getattr(orchestrator, "agents_config", None) or Agents()

        def describe(role: str) -> dict:
            adapter = getattr(orchestrator, role, None)
            provider = config.planner if role == "planner" else config.implementer
            catalog = config.catalog.get(provider, ModelCatalog())
            return {
                "kind": type(adapter).__name__,
                "provider": provider,
                "model": getattr(adapter, "model", "") or "",
                "effort": getattr(adapter, "effort", "") or "",
                "models": catalog.models,
                "efforts": catalog.efforts,
                "default_model": catalog.default_model,
                "default_effort": catalog.default_effort,
            }

        return {
            "planner": describe("planner"),
            "implementer": describe("implementer"),
            "tunable": orchestrator.adapter_factory is not None,
        }
    # ── Health / ready ────────────────────────────────────────────────

    @app.get("/health")
    def health() -> dict[str, str]:
        return {"status": "ok"}

    @app.get("/ready")
    def ready(response: Response) -> dict:
        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
                    adapters[role] = {"kind": type(adapter).__name__, "ready": False, "missing": [str(error)]}
            return adapters

        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,
        }

    return app



