"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.

Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:

- ``define_agent`` — POST /agents/define
- ``patch_agent`` — PATCH /agents/<id>
- ``delete_agent`` — DELETE /agents/<id>

Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.sessions.create_session``.

Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
"""

from __future__ import annotations

import argparse
import re
from dataclasses import dataclass

import httpx

from ratatoskr.sessions import SessionApiFailed

# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")


@dataclass(frozen=True)
class Tier3AgentInfo:
    """Worldtree Tier 3 agent envelope returned by define / patch.

    INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
    constructed server-side from the auth's user_id + the supplied agent_name.
    """

    agent_id: str
    user_id: str
    agent_name: str
    system_prompt: str
    model: str
    created_at: str
    updated_at: str


class Tier3QuotaExceeded(Exception):
    """Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
    on the Heimdall key. ``retry_after`` captures the Retry-After header
    verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""

    def __init__(self, *, retry_after: int) -> None:
        super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
        self.retry_after = retry_after


class Tier3UserIdUnsupported(Exception):
    """Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
    is not slug-safe per Phase 2.0 gate (spec §2626)."""

    def __init__(self) -> None:
        super().__init__("tier3 caller user_id is not slug-safe")


class Tier3FieldNotMutable(Exception):
    """Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
    carried a key that's immutable post-define (``agent_name``, ``user_id``,
    or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""

    def __init__(self, *, field: str | None) -> None:
        super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
        self.field = field


class Tier3LayerDeferred(Exception):
    """Raised on HTTP 422 ``layer_deferred`` — define request carried a
    non-null layer field (``persona`` / ``motivational`` / ``valence`` /
    ``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.

    Note: ``define_agent`` never sends layer fields, so this exception is
    defense-against-server-side-changes / forward-compat. INV-001 in the
    request body construction is the first line of defense.
    """

    def __init__(self, *, field: str | None) -> None:
        super().__init__(f"tier3 layer field deferred: {field!r}")
        self.field = field


class Tier3AgentNotFound(Exception):
    """Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
    (spec §2634 + §2641)."""

    def __init__(self, *, agent_id: str) -> None:
        super().__init__(f"tier3 agent not found: {agent_id!r}")
        self.agent_id = agent_id


def _extract_error_code(resp: httpx.Response) -> str | None:
    """Pluck the ``detail.error_code`` from a Worldtree error envelope.

    Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
    per the spec. Returns None on shape mismatch (so callers fall through
    to the generic ``SessionApiFailed`` branch).
    """
    try:
        body = resp.json()
    except ValueError:
        return None
    detail = body.get("detail") if isinstance(body, dict) else None
    if isinstance(detail, dict):
        code = detail.get("error_code")
        if isinstance(code, str):
            return code
    return None


def _extract_error_field(resp: httpx.Response) -> str | None:
    """Pluck ``detail.field`` from a Worldtree error envelope (used for
    ``field_not_mutable`` and ``layer_deferred`` to surface which field
    triggered the rejection). Returns None on shape mismatch.
    """
    try:
        body = resp.json()
    except ValueError:
        return None
    detail = body.get("detail") if isinstance(body, dict) else None
    if isinstance(detail, dict):
        field = detail.get("field")
        if isinstance(field, str):
            return field
    return None


def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
    """Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
    return Tier3AgentInfo(
        agent_id=body["agent_id"],
        user_id=body["user_id"],
        agent_name=body["agent_name"],
        system_prompt=body["system_prompt"],
        model=body["model"],
        created_at=body["created_at"],
        updated_at=body["updated_at"],
    )


async def define_agent(
    client: httpx.AsyncClient,
    *,
    agent_name: str,
    system_prompt: str,
    model: str,
) -> Tier3AgentInfo:
    """POST /agents/define — create a Tier 3 agent.

    See contract FN define_agent. Validates the agent_name slug client-side
    before the network round-trip; server-side validation is the safety net.
    Returns a fully populated Tier3AgentInfo on 201. Routes documented error
    codes to typed exceptions; unknown non-2xx → SessionApiFailed.
    """
    assert client is not None
    assert _SLUG_RE.match(agent_name), (
        f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
    )
    assert system_prompt, "system_prompt must be non-empty"
    assert model, "model must be non-empty"

    body = {
        "agent_name": agent_name,
        "system_prompt": system_prompt,
        "model": model,
    }
    resp = await client.post("/agents/define", json=body)

    if resp.status_code == 201:
        return _parse_tier3_agent_info(resp.json())
    if resp.status_code == 429:
        # Spec §2675: 51st define → 429 with Retry-After: 0.
        try:
            retry_after = int(resp.headers.get("Retry-After", "0"))
        except (TypeError, ValueError):
            retry_after = 0
        raise Tier3QuotaExceeded(retry_after=retry_after)
    if resp.status_code == 403:
        if _extract_error_code(resp) == "tier3_user_id_unsupported":
            raise Tier3UserIdUnsupported()
    if resp.status_code == 422:
        code = _extract_error_code(resp)
        if code == "layer_deferred":
            raise Tier3LayerDeferred(field=_extract_error_field(resp))
    raise SessionApiFailed(status=resp.status_code, body=resp.content)


async def patch_agent(
    client: httpx.AsyncClient,
    agent_id: str,
    *,
    system_prompt: str | None = None,
    model: str | None = None,
) -> Tier3AgentInfo:
    """PATCH /agents/<id> — mutate system_prompt and/or model.

    See contract FN patch_agent. Per spec §2641: only system_prompt + model
    are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
    """
    assert client is not None
    assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
    assert system_prompt is not None or model is not None, (
        "patch requires at least one of system_prompt or model"
    )

    body: dict[str, str] = {}
    if system_prompt is not None:
        body["system_prompt"] = system_prompt
    if model is not None:
        body["model"] = model
    resp = await client.patch(f"/agents/{agent_id}", json=body)

    if resp.status_code == 200:
        return _parse_tier3_agent_info(resp.json())
    if resp.status_code == 404:
        raise Tier3AgentNotFound(agent_id=agent_id)
    if resp.status_code == 422:
        code = _extract_error_code(resp)
        if code == "field_not_mutable":
            raise Tier3FieldNotMutable(field=_extract_error_field(resp))
    raise SessionApiFailed(status=resp.status_code, body=resp.content)


async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
    """DELETE /agents/<id> — owner hard-delete (cancels active sessions
    server-side per spec §2636).

    See contract FN delete_agent. 204 on success; 404 if the agent_id
    doesn't exist; other non-2xx → SessionApiFailed.
    """
    assert client is not None
    assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"

    resp = await client.delete(f"/agents/{agent_id}")
    if resp.status_code == 204:
        return
    if resp.status_code == 404:
        raise Tier3AgentNotFound(agent_id=agent_id)
    raise SessionApiFailed(status=resp.status_code, body=resp.content)


# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
#
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
# 21 network. Outbound requests carry the same User-Agent string.


class _Tier3UsageError(Exception):
    """Argparse usage violation → exit 10."""


class _Tier3AuthError(Exception):
    """No API key resolvable → exit 11."""


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="python -m ratatoskr.tier3",
        description="Worldtree Tier 3 (consumer-defined) agent lifecycle.",
    )
    parser.add_argument("--api-key", dest="api_key", default=None)
    parser.add_argument("--server", dest="server", default=None)
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_define = sub.add_parser("define", help="Create a Tier 3 agent.")
    p_define.add_argument("--name", required=True, help="agent_name (slug).")
    p_define.add_argument(
        "--system-prompt", dest="system_prompt", required=True,
        help="System prompt the agent ships with.",
    )
    p_define.add_argument(
        "--model", required=True,
        help="Provider model ID (NOT a profile alias; e.g., qwen3.6-35-a3b).",
    )

    p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
    p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
    p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
    p_patch.add_argument("--model", default=None)

    p_delete = sub.add_parser("delete", help="Hard-delete a Tier 3 agent.")
    p_delete.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')

    return parser


def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
    """Resolve API key + server URL with the same env-var fallback as cli.py."""
    import os

    api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
    if not api_key:
        raise _Tier3AuthError("no API key (set --api-key or WORLDTREE_API_KEY)")
    server_url = (
        ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
    )
    return api_key, server_url


async def _run_define(ns: argparse.Namespace) -> int:
    api_key, server_url = _resolve_auth(ns)
    from ratatoskr.cli import USER_AGENT
    from ratatoskr.local_agents import (
        LocalAgentEntry,
        add_local_agent,
        make_description,
    )

    async with httpx.AsyncClient(
        base_url=server_url,
        headers={
            "Authorization": f"Bearer {api_key}",
            "User-Agent": USER_AGENT,
        },
        timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
    ) as client:
        info = await define_agent(
            client,
            agent_name=ns.name,
            system_prompt=ns.system_prompt,
            model=ns.model,
        )
        # v0.8.0: persist to local index so the picker can show it.
        add_local_agent(
            LocalAgentEntry(
                agent_id=info.agent_id,
                agent_name=info.agent_name,
                model=info.model,
                description=make_description(info.system_prompt),
                defined_at=info.created_at,
            )
        )
        print(f"defined {info.agent_id} ({info.model})")
        return 0


async def _run_patch(ns: argparse.Namespace) -> int:
    api_key, server_url = _resolve_auth(ns)
    from ratatoskr.cli import USER_AGENT
    from ratatoskr.local_agents import (
        LocalAgentEntry,
        make_description,
        update_local_agent,
    )

    if ns.system_prompt is None and ns.model is None:
        raise _Tier3UsageError(
            "patch requires at least one of --system-prompt or --model"
        )
    async with httpx.AsyncClient(
        base_url=server_url,
        headers={
            "Authorization": f"Bearer {api_key}",
            "User-Agent": USER_AGENT,
        },
        timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
    ) as client:
        info = await patch_agent(
            client,
            ns.agent_id,
            system_prompt=ns.system_prompt,
            model=ns.model,
        )
        # v0.8.0: refresh local index with the post-patch state.
        update_local_agent(
            LocalAgentEntry(
                agent_id=info.agent_id,
                agent_name=info.agent_name,
                model=info.model,
                description=make_description(info.system_prompt),
                defined_at=info.updated_at,
            )
        )
        print(f"patched {info.agent_id}")
        return 0


async def _run_delete(ns: argparse.Namespace) -> int:
    api_key, server_url = _resolve_auth(ns)
    from ratatoskr.cli import USER_AGENT
    from ratatoskr.local_agents import remove_local_agent

    async with httpx.AsyncClient(
        base_url=server_url,
        headers={
            "Authorization": f"Bearer {api_key}",
            "User-Agent": USER_AGENT,
        },
        timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
    ) as client:
        await delete_agent(client, ns.agent_id)
        # v0.8.0: drop from local index so the picker stops listing it.
        remove_local_agent(ns.agent_id)
        print(f"deleted {ns.agent_id}")
        return 0


def main(argv: list[str] | None = None) -> int:
    """Sync entry point — argparse + dispatch + error → exit-code mapping.

    Mirrors ratatoskr.cli.main()'s error-routing matrix:
      0  happy
      10 usage error
      11 auth error
      20 api-failure (typed exception or generic SessionApiFailed)
      21 network error
    """
    import asyncio
    import sys

    parser = _build_parser()
    try:
        ns = parser.parse_args(argv)
    except SystemExit as exc:
        return int(exc.code) if exc.code is not None else 0

    handler = {
        "define": _run_define,
        "patch": _run_patch,
        "delete": _run_delete,
    }[ns.cmd]

    try:
        return asyncio.run(handler(ns))
    except _Tier3UsageError as exc:
        sys.stderr.write(f"[usage_error] {exc}\n")
        return 10
    except _Tier3AuthError as exc:
        sys.stderr.write(f"[auth_error] {exc}\n")
        return 11
    except AssertionError as exc:
        sys.stderr.write(f"[usage_error] {exc}\n")
        return 10
    except Tier3QuotaExceeded as exc:
        sys.stderr.write(f"[quota_exceeded] retry_after={exc.retry_after}\n")
        return 20
    except Tier3UserIdUnsupported:
        sys.stderr.write("[user_id_unsupported]\n")
        return 20
    except Tier3AgentNotFound as exc:
        sys.stderr.write(f"[agent_not_found] {exc.agent_id}\n")
        return 20
    except Tier3FieldNotMutable as exc:
        sys.stderr.write(f"[field_not_mutable] field={exc.field}\n")
        return 20
    except Tier3LayerDeferred as exc:
        sys.stderr.write(f"[layer_deferred] field={exc.field}\n")
        return 20
    except SessionApiFailed as exc:
        sys.stderr.write(
            f"[api_failed] status={exc.status} body={exc.body!r}\n"
        )
        return 20
    except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
        sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
        return 21


if __name__ == "__main__":
    import sys

    sys.exit(main())
