"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip.

Three primitives:

- `post_resolution_note` — post the engineer-edited resolution markdown to
  the PSA ticket, store `{external_id, posted_at}` on the session.
- `post_escalation_package` — same pattern for the Escalate flow.
- `transition_ticket_status` — patch the ticket status, then re-fetch and
  verify the change actually took. Failed verification raises loudly so the
  UI never reports silent success (per the existing ConnectWise integration
  principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md).

The target status IDs live in `account_settings.preferences`
(`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status
transition is a no-op and the endpoint response says so — we do not guess a
default because CW status IDs are board-specific.

Local-only path: callers handle sessions without `psa_ticket_id` before
calling this service. Nothing here tries to "post locally" — the service's
job ends at the PSA boundary.
"""
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.account_settings import AccountSettings
from app.models.ai_session import AISession
from app.services.psa.exceptions import PSAConnectionError
from app.services.psa.registry import get_provider_for_connection
from app.services.psa.types import NoteType

logger = logging.getLogger(__name__)


class PSAStatusVerificationError(RuntimeError):
    """Raised when a ticket status transition didn't stick on re-fetch.

    The `update_ticket_status` call returned OK but the subsequent
    `get_ticket` still shows the prior status (or some unrelated one).
    This is the exact failure mode CLAUDE.md flags as a ConnectWise
    anti-pattern: reporting success when nothing changed.
    """

    def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None:
        super().__init__(
            f"Ticket {ticket_id} status transition to {expected_status_id} "
            f"did not verify — observed {observed_status!r} after re-fetch."
        )
        self.ticket_id = ticket_id
        self.expected_status_id = expected_status_id
        self.observed_status = observed_status


class PSAWritebackService:
    """Thin orchestration over the PSA provider for FlowPilot writebacks.

    Instances are per-request — the AsyncSession is the one handling the
    current HTTP call, and the provider is resolved lazily from the session's
    `psa_connection_id`.
    """

    def __init__(self, db: AsyncSession) -> None:
        self.db = db

    # ── Public API ────────────────────────────────────────────────────────

    async def post_resolution_note(
        self, session: AISession, markdown: str
    ) -> dict[str, Any]:
        """Post `markdown` as a resolution note on the linked CW ticket.

        On success, persists `resolution_note_markdown`, `_posted_at`,
        `_external_id` on the session and returns the same triple. Caller is
        responsible for committing the transaction.
        """
        return await self._post_note(
            session=session,
            markdown=markdown,
            note_type=NoteType.RESOLUTION,
            markdown_col="resolution_note_markdown",
            posted_at_col="resolution_note_posted_at",
            external_id_col="resolution_note_external_id",
            kind="resolution",
        )

    async def post_escalation_package(
        self, session: AISession, markdown: str
    ) -> dict[str, Any]:
        """Post `markdown` as an escalation handoff note on the CW ticket."""
        return await self._post_note(
            session=session,
            markdown=markdown,
            # Internal-analysis visibility: the handoff is for the next engineer,
            # not the customer. CW fires no notifications, keeps the note internal.
            note_type=NoteType.INTERNAL_ANALYSIS,
            markdown_col="escalation_package_markdown",
            posted_at_col="escalation_package_posted_at",
            external_id_col="escalation_package_external_id",
            kind="escalation",
        )

    async def transition_ticket_status(
        self,
        session: AISession,
        target_status_id: int,
    ) -> dict[str, Any]:
        """PATCH ticket status, then re-fetch and verify.

        Returns `{"success": True, "verified_status_id": <int>, "verified_status_name": <str>}`
        when the observed status matches. Raises `PSAStatusVerificationError`
        when the transition didn't take (most common real-world failure: CW
        requires certain fields before allowing a status change to
        Resolved — the PATCH returns 200 but the status silently stays put).
        """
        if not session.psa_ticket_id or not session.psa_connection_id:
            raise ValueError("Session has no linked PSA ticket for status transition")

        provider = await get_provider_for_connection(session.psa_connection_id, self.db)
        await provider.update_ticket_status(
            ticket_id=session.psa_ticket_id, status_id=target_status_id,
        )

        # Verify by re-fetch — this is the load-bearing step.
        verification = await provider.get_ticket(session.psa_ticket_id)
        observed_id = getattr(verification, "status_id", None)
        observed_name = getattr(verification, "status_name", None)
        if observed_id != target_status_id:
            raise PSAStatusVerificationError(
                ticket_id=session.psa_ticket_id,
                expected_status_id=target_status_id,
                observed_status={"id": observed_id, "name": observed_name},
            )

        return {
            "success": True,
            "verified_status_id": observed_id,
            "verified_status_name": observed_name,
        }

    async def resolved_status_id_for_account(
        self, account_id: UUID
    ) -> int | None:
        """Return the configured CW "Resolved" status ID for the account.

        None means "no transition configured" — callers should skip the
        transition (posting the note is still meaningful). This lives in
        account_settings.preferences per the Phase 1 JSONB grab-bag design.
        """
        raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None)
        return self._coerce_status_id(raw)

    async def escalated_status_id_for_account(
        self, account_id: UUID
    ) -> int | None:
        raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None)
        return self._coerce_status_id(raw)

    # ── Internals ─────────────────────────────────────────────────────────

    async def _post_note(
        self,
        *,
        session: AISession,
        markdown: str,
        note_type: str,
        markdown_col: str,
        posted_at_col: str,
        external_id_col: str,
        kind: str,
    ) -> dict[str, Any]:
        if not session.psa_ticket_id or not session.psa_connection_id:
            raise ValueError(f"Session has no linked PSA ticket for {kind} post")

        markdown = (markdown or "").strip()
        if not markdown:
            raise ValueError(f"{kind} markdown is empty")

        try:
            provider = await get_provider_for_connection(session.psa_connection_id, self.db)
        except PSAConnectionError:
            # Connection could have been deleted or deactivated since session
            # creation — propagate as a clear error for the endpoint to surface.
            logger.exception(
                "PSA connection %s is no longer available for session %s",
                session.psa_connection_id, session.id,
            )
            raise

        posted = await provider.post_note(
            ticket_id=session.psa_ticket_id,
            text=markdown,
            note_type=note_type,
        )

        posted_at = datetime.now(timezone.utc)
        setattr(session, markdown_col, markdown)
        setattr(session, posted_at_col, posted_at)
        setattr(session, external_id_col, str(posted.id) if posted.id else None)

        return {
            "external_id": str(posted.id) if posted.id else None,
            "posted_at": posted_at,
            "kind": kind,
        }

    @staticmethod
    def _coerce_status_id(raw: Any) -> int | None:
        if raw is None:
            return None
        try:
            return int(raw)
        except (TypeError, ValueError):
            logger.warning(
                "Non-integer CW status ID in account_settings.preferences: %r",
                raw,
            )
            return None
