"""Convex tool layer client — the LiveKit agent's bridge to the app backend.

Wraps the HTTP endpoints built per LIVEKIT_VOICE_TOOL_CONTRACT.md:
  POST /voice/session                       — call-start bootstrap (gates, prompt, manifest)
  POST /voice/tools/search_knowledge        — selective-RAG snippets
  POST /voice/tools/lookup_data             — run one org custom API tool
  POST /voice/tools/run_workflow            — deterministic workflow engine
  POST /voice/tools/escalate                — flip conversation to a human
  POST /voice/tools/record_answer           — record one campaign answer live
  POST /voice/persist                       — idempotent transcript sink

All requests carry `x-organization-id` and `x-voice-secret`.
"""

from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import Any

import httpx

DEFAULT_TIMEOUT = 15.0


@dataclass
class VoiceSession:
    """Parsed /voice/session response."""

    ok: bool
    gate: str | None
    refusal_message: str | None
    conversation_id: str | None
    thread_id: str | None
    language: str
    system_prompt: str
    tools: dict[str, Any] = field(default_factory=dict)
    campaign: dict[str, Any] | None = None
    scripts: dict[str, Any] | None = None
    agent_id: str | None = None
    dialect: str | None = None
    pool: dict[str, Any] | None = None
    escalation: dict[str, Any] | None = None
    # Story 2 — { provider, voiceId, speed?, gender? } from Agent Builder
    voice: dict[str, Any] | None = None
    # Phase 3 — Whisper language hint + barge-in / VAD from Agent Builder
    transcription: dict[str, Any] | None = None
    turn_taking: dict[str, Any] | None = None


class ConvexVoiceClient:
    def __init__(
        self,
        base_url: str | None = None,
        secret: str | None = None,
        organization_id: str | None = None,
    ) -> None:
        self.base_url = (base_url or os.environ["CONVEX_SITE_URL"]).rstrip("/")
        self.secret = secret or os.environ.get("VOICE_TOOL_SECRET", "")
        self.organization_id = organization_id or ""
        self.agent_id: str | None = None
        self._http = httpx.AsyncClient(timeout=DEFAULT_TIMEOUT)

    def set_organization_id(self, organization_id: str) -> None:
        self.organization_id = organization_id or ""

    def set_agent_id(self, agent_id: str | None) -> None:
        """Bind agent so in-call tools (e.g. search_knowledge) inherit KB scope."""
        self.agent_id = (agent_id or "").strip() or None

    def _headers(self) -> dict[str, str]:
        return {
            "content-type": "application/json",
            "x-organization-id": self.organization_id,
            "x-voice-secret": self.secret,
        }

    async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
        resp = await self._http.post(
            f"{self.base_url}{path}", json=body, headers=self._headers()
        )
        resp.raise_for_status()
        return resp.json()

    async def resolve_inbound(self, dialed_number: str) -> dict[str, Any]:
        """Story 7 — DID → org + inbound agent (org header may be empty)."""
        headers = {
            "content-type": "application/json",
            "x-voice-secret": self.secret,
            "x-organization-id": self.organization_id or "resolve",
        }
        resp = await self._http.post(
            f"{self.base_url}/voice/resolve-inbound",
            json={"dialedNumber": dialed_number},
            headers=headers,
        )
        resp.raise_for_status()
        return resp.json()

    # ── A. session bootstrap ────────────────────────────────────────────────
    async def get_session(
        self,
        call_id: str,
        caller_number: str | None = None,
        campaign_id: str | None = None,
        attempt_id: str | None = None,
        agent_id: str | None = None,
        dialed_number: str | None = None,
        dialect_override: str | None = None,
        language_override: str | None = None,
    ) -> VoiceSession:
        body: dict[str, Any] = {"callId": call_id}
        if caller_number:
            body["callerNumber"] = caller_number
        if campaign_id:
            body["campaignId"] = campaign_id
        if attempt_id:
            body["attemptId"] = attempt_id
        if agent_id:
            body["agentId"] = agent_id
        if dialed_number:
            body["dialedNumber"] = dialed_number
        if dialect_override:
            body["dialectOverride"] = dialect_override
        if language_override:
            body["languageOverride"] = language_override
        d = await self._post("/voice/session", body)
        session = VoiceSession(
            ok=bool(d.get("ok")),
            gate=d.get("gate"),
            refusal_message=d.get("refusalMessage"),
            conversation_id=d.get("conversationId"),
            thread_id=d.get("threadId"),
            language=d.get("language") or "",
            system_prompt=d.get("systemPrompt", ""),
            tools=d.get("tools") or {},
            campaign=d.get("campaign"),
            scripts=d.get("scripts"),
            agent_id=d.get("agentId"),
            dialect=d.get("dialect"),
            pool=d.get("pool"),
            escalation=d.get("escalation"),
            voice=d.get("voice"),
            transcription=d.get("transcription"),
            turn_taking=d.get("turnTaking") or d.get("turn_taking"),
        )
        # Prefer session-resolved agent (DID / default) so KB scope stays in sync.
        self.set_agent_id(session.agent_id or agent_id)
        return session

    async def queue_status(self, call_id: str) -> dict[str, Any]:
        """Poll inbound pool queue status (Story 13 hold path)."""
        return await self._post("/voice/queue/status", {"callId": call_id})

    async def queue_abandon(self, call_id: str) -> dict[str, Any]:
        """Mark a still-queued hold call as abandoned."""
        return await self._post("/voice/queue/abandon", {"callId": call_id})

    # ── B. in-call tools ────────────────────────────────────────────────────
    async def search_knowledge(self, query: str, limit: int | None = None) -> dict:
        body: dict[str, Any] = {"query": query}
        if limit:
            body["limit"] = limit
        if self.agent_id:
            body["agentId"] = self.agent_id
        return await self._post("/voice/tools/search_knowledge", body)

    async def lookup_data(
        self, tool_name: str, tool_args: dict[str, Any], conversation_id: str
    ) -> dict:
        return await self._post(
            "/voice/tools/lookup_data",
            {"toolName": tool_name, "args": tool_args, "conversationId": conversation_id},
        )

    async def run_workflow(
        self, workflow_name: str, inputs: dict[str, Any], conversation_id: str
    ) -> dict:
        return await self._post(
            "/voice/tools/run_workflow",
            {"workflowName": workflow_name, "inputs": inputs, "conversationId": conversation_id},
        )

    async def escalate(self, call_id: str, reason: str | None = None) -> dict:
        body: dict[str, Any] = {"callId": call_id}
        if reason:
            body["reason"] = reason
        return await self._post("/voice/tools/escalate", body)

    async def transfer_call(
        self,
        call_id: str,
        to: str | None = None,
        room_name: str | None = None,
        reason: str | None = None,
    ) -> dict:
        body: dict[str, Any] = {"callId": call_id}
        if to:
            body["to"] = to
        if room_name:
            body["roomName"] = room_name
        if reason:
            body["reason"] = reason
        return await self._post("/voice/tools/transfer_call", body)

    async def archive_call(
        self,
        call_id: str,
        conversation_id: str | None = None,
        transcript: str | None = None,
        recording_url: str | None = None,
        duration_seconds: float | None = None,
        language: str | None = None,
    ) -> dict:
        body: dict[str, Any] = {"callId": call_id}
        if conversation_id:
            body["conversationId"] = conversation_id
        if transcript:
            body["transcript"] = transcript
        if recording_url:
            body["recordingUrl"] = recording_url
        if duration_seconds is not None:
            body["durationSeconds"] = duration_seconds
        if language:
            body["language"] = language
        return await self._post("/voice/archive", body)

    async def record_answer(self, attempt_id: str, question_id: str, answer: str) -> dict:
        return await self._post(
            "/voice/tools/record_answer",
            {"attemptId": attempt_id, "questionId": question_id, "answer": answer},
        )

    # ── C. persistence sink ─────────────────────────────────────────────────
    async def persist_turn(
        self,
        call_id: str,
        turn_index: int,
        role: str,
        content: str,
        thread_id: str,
        timestamp_ms: int | None = None,
        offset_ms: int | None = None,
    ) -> dict:
        body: dict[str, Any] = {
            "callId": call_id,
            "turnIndex": turn_index,
            "role": role,
            "content": content,
            "threadId": thread_id,
        }
        if timestamp_ms is not None:
            body["timestampMs"] = timestamp_ms
        if offset_ms is not None:
            body["offsetMs"] = offset_ms
        return await self._post("/voice/persist", body)

    async def patch_recording_url(
        self,
        call_id: str,
        recording_url: str,
        duration_seconds: float | None = None,
    ) -> dict:
        """Late recording URL after egress finishes (Story 9)."""
        body: dict[str, Any] = {
            "callId": call_id,
            "recordingUrl": recording_url,
        }
        if duration_seconds is not None:
            body["durationSeconds"] = duration_seconds
        return await self._post("/voice/recording", body)

    async def aclose(self) -> None:
        await self._http.aclose()
