"""Host / HTTP 共用的 JSON-RPC 风格请求调度（与 ``aicd host`` 每行 JSON 语义一致）。"""

from __future__ import annotations

import asyncio
import json
import os
import sys
import tempfile
from pathlib import Path
from typing import Any

from aicd.capabilities import build_capabilities_payload, capabilities_json_text
from aicd.host_tokens import verify_host_nonce
from aicd.invocation import InvocationContext, load_invocation_context, subprocess_env_for_child
from aicd.runtime import RuntimeContext
from aicd.tasks.safe_ids import validate_topic_slug
from aicd.tui_prefs import parse_main_chat_mode_token
from aicd.ui.cli_headless import one_chat_turn
from aicd.workspace_chat_once import resolve_host_chat_workspace

HOST_WORKSPACE_BATCH_MAX = 16

# Host / HTTP 仅允许直接 tool_invoke 的高风险子集（避免任意工具外传）
HOST_TOOL_ALLOWLIST = frozenset(
    {
        "plugin_list",
        "plugin_info",
        "plugin_load",
        "plugin_reload",
        "task_list",
        "task_status",
        "task_timeline",
        "task_result",
    }
)


def host_ok(req_id: Any, result: dict[str, Any]) -> dict[str, Any]:
    return {"ok": True, "id": req_id, "result": result}


def host_err(req_id: Any, message: str, code: str | None = None) -> dict[str, Any]:
    out: dict[str, Any] = {"ok": False, "id": req_id, "error": message}
    if code:
        out["code"] = code
    return out


def _coerce_host_bool_flag(value: Any, *, default: bool) -> bool:
    """将 JSON-RPC params 中的标量转成 bool；避免 ``bool(\"false\")`` 为真。"""
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0
    if isinstance(value, str):
        s = value.strip().lower()
        if s in ("0", "false", "no", "off", ""):
            return False
        if s in ("1", "true", "yes", "on"):
            return True
    return bool(value)


def _canonical_main_chat_mode_or_err(params: dict[str, Any]) -> tuple[str | None, str | None]:
    """解析 ``main_chat_mode``；无参数返回 (None, None)；非法返回 (None, error_msg)。"""
    mo_raw = params.get("main_chat_mode")
    if mo_raw in (None, ""):
        return None, None
    mo_s = str(mo_raw).strip()
    if not mo_s:
        return None, None
    canon, err = parse_main_chat_mode_token(mo_s)
    if canon is None:
        return None, err or "invalid main_chat_mode"
    return canon, None


async def chat_once_via_subprocess(
    ctx: RuntimeContext,
    *,
    workspace: Path,
    message: str,
    session_id: str | None,
    no_plugins: bool = False,
    main_chat_mode: str | None = None,
) -> dict[str, Any]:
    """在独立子进程中跑 ``aicd chat-once``，避免在 Host 事件循环内嵌套完整 Runtime。"""

    parent_inv = (
        ctx.invocation
        if isinstance(ctx.invocation, InvocationContext)
        else load_invocation_context()
    )
    env = subprocess_env_for_child(
        parent_inv,
        spawned_by="host_api",
        parent_plugin_id=parent_inv.parent_plugin_id,
        host_nonce=parent_inv.host_nonce,
        plugin_policy=parent_inv.plugin_policy or None,
    )
    env.pop("AICD_HOST_MODE", None)
    env.pop("AICD_HOST_REQUIRE_NONCE", None)

    mpath: str | None = None
    proc: asyncio.subprocess.Process | None = None
    out_b = b""
    err_b = b""
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            suffix=".txt",
            delete=False,
        ) as f:
            mpath = f.name
            f.write(message)

        cmd: list[str] = [
            sys.executable,
            "-m",
            "aicd",
            "chat-once",
            "--home",
            str(ctx.home.resolve()),
            "--work",
            str(workspace.resolve()),
            "--message-file",
            mpath,
            "--skip-setup",
            "--output-json",
        ]
        if no_plugins:
            cmd.append("--no-plugins")
        if session_id:
            cmd.extend(["--session-id", session_id])
        if main_chat_mode:
            cmd.extend(["--main-chat-mode", main_chat_mode])

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            env=env,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        out_b, err_b = await proc.communicate()
    finally:
        if mpath:
            try:
                os.unlink(mpath)
            except OSError:
                pass

    out_s = (out_b or b"").decode("utf-8", errors="replace")
    err_s = (err_b or b"").decode("utf-8", errors="replace")
    lines = [ln for ln in out_s.strip().splitlines() if ln.strip()]
    line0 = lines[0] if lines else ""
    if proc is None:
        return {
            "ok": False,
            "error": "chat-once subprocess was not started",
            "stderr": err_s[:8000],
            "stdout": out_s[:8000],
            "workspace": str(workspace),
        }
    if proc.returncode != 0:
        return {
            "ok": False,
            "error": f"chat-once exit {proc.returncode}",
            "stderr": err_s[:8000],
            "stdout": out_s[:8000],
            "workspace": str(workspace),
        }
    try:
        parsed: Any = json.loads(line0)
    except json.JSONDecodeError as e:
        return {
            "ok": False,
            "error": f"invalid json from chat-once: {e}",
            "stdout": out_s[:8000],
            "stderr": err_s[:8000],
        }
    if not isinstance(parsed, dict):
        return {"ok": False, "error": "chat-once json not object"}
    return parsed


async def dispatch_host_request(
    ctx: RuntimeContext,
    req: dict[str, Any],
    *,
    state_dir: Any,
    require_nonce: bool,
) -> dict[str, Any]:
    """处理一条与 stdio Host 相同的请求 dict，返回响应 dict。"""

    req_id = req.get("id")
    method = str(req.get("method") or "").strip()
    params = req.get("params")
    if not isinstance(params, dict):
        params = {}

    auth = req.get("auth")
    auth_nonce: str | None = None
    if isinstance(auth, dict):
        raw = auth.get("nonce")
        if isinstance(raw, str) and raw.strip():
            auth_nonce = raw.strip()

    if require_nonce or os.environ.get("AICD_HOST_REQUIRE_NONCE") == "1":
        token = auth_nonce or ""
        if not token:
            return host_err(req_id, "auth.nonce required", code="auth")
        ok, msg = verify_host_nonce(Path(str(state_dir)), token)
        if not ok:
            return host_err(req_id, f"nonce: {msg}", code="auth")

    inv_raw = ctx.invocation
    inv: InvocationContext | None = (
        inv_raw if isinstance(inv_raw, InvocationContext) else None
    )
    boot_loaded = bool(ctx.plugin_registry and (ctx.plugin_registry.summarize()))

    if method == "ping":
        from aicd.version import VERSION

        return host_ok(req_id, {"pong": True, "version": VERSION})

    if method == "capabilities":
        payload = build_capabilities_payload(
            plugin_registry=ctx.plugin_registry,
            invocation=inv,
            plugins_boot_loaded=boot_loaded,
        )
        return host_ok(req_id, {"capabilities": payload})

    if method == "capabilities_json":
        payload = build_capabilities_payload(
            plugin_registry=ctx.plugin_registry,
            invocation=inv,
            plugins_boot_loaded=boot_loaded,
        )
        return host_ok(req_id, {"text": capabilities_json_text(payload)})

    if method == "task_ai":
        instruction = str(params.get("instruction") or "").strip()
        if not instruction:
            return host_err(req_id, "instruction required", code="args")
        topic = params.get("topic_slug")
        ts: str | None = None
        if isinstance(topic, str) and topic.strip():
            try:
                ts = validate_topic_slug(topic.strip())
            except ValueError as e:
                return host_err(req_id, str(e), code="args")
        try:
            tid = await ctx.tasks.create_ai_task(
                instruction,
                parent_id=None,
                topic_slug=ts,
                read_only_paths=None,
                parent_handoff_expectation=None,
                thread_root_id=ctx.agent.chat_session_id,
                display_title=None,
                purpose=None,
                estimated_steps=None,
            )
        except ValueError as e:
            return host_err(req_id, str(e), code="task")
        return host_ok(req_id, {"task_id": tid})

    if method in ("main_chat", "chat_turn"):
        message = str(params.get("message") or "").strip()
        if not message:
            return host_err(req_id, "message required", code="args")
        mo_raw = params.get("main_chat_mode")
        override_canon: str | None = None
        if mo_raw not in (None, ""):
            mo_s = str(mo_raw).strip()
            if mo_s:
                canon, err = parse_main_chat_mode_token(mo_s)
                if canon is None:
                    return host_err(
                        req_id,
                        err or "invalid main_chat_mode",
                        code="args",
                    )
                override_canon = canon
        tool_lines: list[str] = []

        def _emit(msg: str) -> None:
            tool_lines.append(msg)

        code, reply = await one_chat_turn(
            ctx,
            message,
            emit=_emit,
            main_chat_mode_override=override_canon,
        )
        if code != 0:
            return {
                "ok": False,
                "id": req_id,
                "code": "chat",
                "error": "main_chat failed",
                "tool_lines": tool_lines,
            }
        return host_ok(
            req_id,
            {
                "reply": reply or "",
                "tool_lines": tool_lines,
                "main_chat_mode_override": override_canon,
            },
        )

    if method == "main_chat_mode_set":
        raw_m = params.get("mode")
        if raw_m is None or str(raw_m).strip() == "":
            return host_ok(
                req_id,
                {"main_chat_mode": ctx.agent.main_chat_mode},
            )
        ok, msg = ctx.agent.set_main_chat_mode(str(raw_m))
        if msg == "help":
            return host_err(
                req_id,
                "mode required: default | chat | doc",
                code="args",
            )
        if not ok:
            return host_err(req_id, msg, code="args")
        persist = _coerce_host_bool_flag(params.get("persist"), default=True)
        if persist:
            ctx.persist_tui_main_chat_mode(msg)
        return host_ok(
            req_id,
            {"main_chat_mode": msg, "persisted": persist},
        )

    if method in ("main_chat_supplement", "enqueue_main_chat_supplement"):
        text = str(
            params.get("text") or params.get("message") or ""
        ).strip()
        if not text:
            return host_err(req_id, "text required", code="args")
        depth, enqueued = await ctx.agent.enqueue_chat_supplement(text)
        return host_ok(
            req_id,
            {
                "queue_depth": depth,
                "enqueued": enqueued,
            },
        )

    if method == "main_inbox_continue":
        from aicd.agent.idle_pulse import main_lifecycle_wakeup_user_text
        from aicd.tasks.main_inbox import main_inbox_id

        lifecycle_only = _coerce_host_bool_flag(
            params.get("lifecycle_only"), default=True
        )
        custom = str(params.get("message") or "").strip()
        to_id = main_inbox_id(ctx.agent.chat_session_id)
        rows = await ctx.store.list_task_messages_unread(
            ctx.conn,
            to_task_id=to_id,
            limit=50,
        )
        if lifecycle_only and rows:
            rows = [r for r in rows if (r.kind or "") == "lifecycle"]
        if not rows:
            return host_ok(
                req_id,
                {
                    "skipped": True,
                    "reason": "no_unread_matching_messages",
                    "lifecycle_only": lifecycle_only,
                },
            )
        tool_lines: list[str] = []

        def _emit(msg: str) -> None:
            tool_lines.append(msg)

        msg = custom if custom else main_lifecycle_wakeup_user_text()
        mo_raw = params.get("main_chat_mode")
        override_canon: str | None = None
        if mo_raw not in (None, ""):
            mo_s = str(mo_raw).strip()
            if mo_s:
                canon, err = parse_main_chat_mode_token(mo_s)
                if canon is None:
                    return host_err(
                        req_id,
                        err or "invalid main_chat_mode",
                        code="args",
                    )
                override_canon = canon
        code, reply = await one_chat_turn(
            ctx,
            msg,
            emit=_emit,
            main_chat_mode_override=override_canon,
        )
        if code != 0:
            return {
                "ok": False,
                "id": req_id,
                "code": "chat",
                "error": "main_inbox_continue failed",
                "tool_lines": tool_lines,
            }
        return host_ok(
            req_id,
            {
                "reply": reply or "",
                "tool_lines": tool_lines,
                "drained_unread_n": len(rows),
                "lifecycle_only": lifecycle_only,
            },
        )

    if method == "main_chat_session_attach":
        raw = params.get("chat_session_id")
        sid = str(raw).strip() if raw not in (None, "") else ""
        if not sid:
            return host_err(req_id, "chat_session_id required", code="args")
        try:
            await ctx.agent.attach_chat_session_id(sid)
        except ValueError as e:
            return host_err(req_id, str(e), code="args")
        return host_ok(req_id, {"chat_session_id": sid})

    if method == "workspace_set":
        raw = params.get("path") or params.get("workspace")
        p = str(raw or "").strip()
        if not p:
            return host_err(req_id, "path or workspace required", code="args")
        ok_ws, msg = ctx.set_ai_workspace(p)
        if not ok_ws:
            return host_err(req_id, msg, code="args")
        return host_ok(
            req_id,
            {"workspace": str(ctx.workspace_cwd.resolve())},
        )

    if method == "main_chat_workspace":
        message = str(params.get("message") or "").strip()
        if not message:
            return host_err(req_id, "message required", code="args")
        wp, werr = resolve_host_chat_workspace(ctx, params)
        if werr or wp is None:
            return host_err(req_id, werr or "workspace resolution failed", code="args")
        sid_raw = params.get("session_id")
        sid = str(sid_raw).strip() if sid_raw not in (None, "") else None
        np = bool(params.get("no_plugins"))
        mo_canon, mo_err = _canonical_main_chat_mode_or_err(params)
        if mo_err:
            return host_err(req_id, mo_err, code="args")
        sub = await chat_once_via_subprocess(
            ctx,
            workspace=wp,
            message=message,
            session_id=sid,
            no_plugins=np,
            main_chat_mode=mo_canon,
        )
        if sub.get("ok") is False:
            return {
                "ok": False,
                "id": req_id,
                "code": "chat_workspace",
                "error": sub.get("error", "subprocess chat-once failed"),
                "detail": sub,
            }
        return host_ok(req_id, sub)

    if method == "main_chat_workspace_batch":
        items = params.get("items")
        if not isinstance(items, list) or not items:
            return host_err(req_id, "items must be a non-empty list", code="args")
        if len(items) > HOST_WORKSPACE_BATCH_MAX:
            return host_err(
                req_id,
                f"at most {HOST_WORKSPACE_BATCH_MAX} items",
                code="args",
            )

        async def _one(it: dict[str, Any]) -> dict[str, Any]:
            msg = str(it.get("message") or "").strip()
            if not msg:
                return {"ok": False, "error": "empty message"}
            wpath, err = resolve_host_chat_workspace(ctx, it)
            if err or wpath is None:
                return {"ok": False, "error": err or "no workspace"}
            sraw = it.get("session_id")
            ss = str(sraw).strip() if sraw not in (None, "") else None
            np_item = bool(it.get("no_plugins"))
            mo_canon, mo_err = _canonical_main_chat_mode_or_err(it)
            if mo_err:
                return {"ok": False, "error": mo_err}
            return await chat_once_via_subprocess(
                ctx,
                workspace=wpath,
                message=msg,
                session_id=ss,
                no_plugins=np_item,
                main_chat_mode=mo_canon,
            )

        async def _wrap(idx: int, it: Any) -> dict[str, Any]:
            if not isinstance(it, dict):
                return {"ok": False, "error": "item must be object", "index": idx}
            r = await _one(it)
            r["index"] = idx
            return r

        results = await asyncio.gather(*[_wrap(i, x) for i, x in enumerate(items)])
        return host_ok(req_id, {"results": list(results), "n": len(results)})

    if method == "plugin_load":
        from aicd.agent.handlers import plugin_tools

        raw = plugin_tools.plugin_load(ctx.router, params)
        if raw.get("ok") is False:
            return host_err(req_id, str(raw.get("error") or raw), code="tool")
        return host_ok(req_id, raw)

    if method == "plugin_reload":
        from aicd.agent.handlers import plugin_tools

        raw = plugin_tools.plugin_reload(ctx.router, params)
        if raw.get("ok") is False:
            return host_err(req_id, str(raw.get("error") or raw), code="tool")
        return host_ok(req_id, raw)

    if method == "tool_invoke":
        name = str(params.get("name") or "").strip()
        if name not in HOST_TOOL_ALLOWLIST:
            return host_err(req_id, f"tool not allowlisted: {name}", code="forbidden")
        args = params.get("arguments")
        if args is None:
            args = {}
        if not isinstance(args, dict):
            return host_err(req_id, "arguments must be object", code="args")
        out = await ctx.router._dispatch(name, args)
        return host_ok(req_id, {"tool": name, "return": out})

    return host_err(req_id, f"unknown method: {method}", code="method")
