"""
Support ticket intake + admin management APIs.
"""

from __future__ import annotations

import base64
import enum
import hashlib
import hmac
import json
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from html import escape
from typing import Any, Dict, List, Optional, Literal
from urllib.parse import urlencode
from uuid import uuid4

from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession

from ...auth_core import get_current_identity
from ...auth_security import AdminContext, require_admin
from ... import settings
from ...db import get_session
from ...models import (
    Account,
    Job,
    SupportTicket,
    SupportTicketNote,
    TicketNoteDirection,
    TicketSeverity,
    TicketSource,
    TicketStatus,
    TicketType,
    UsageQuotaModel,
)
from ...services.admin_audit import log_admin_action
from ...services.admin_alerts import notify_support_ticket_created
from ...services.gmail_support import GmailSupportService
from ...services.support_triage import SupportTriageService
from ...services.sos_enrichment import enrich_ticket_with_telemetry
from ...storage import get_storage_backend

log = logging.getLogger(__name__)

router = APIRouter(prefix="/support", tags=["Support"])
admin_router = APIRouter(prefix="/admin/support-tickets", tags=["Admin Support"])

ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024
MAX_ATTACHMENTS_PER_TICKET = 5


def _env_int(name: str, default: int) -> int:
    raw = os.getenv(name)
    if raw is None:
        return default
    try:
        return int(raw)
    except (TypeError, ValueError):
        return default


SUPPORT_TICKET_RATE_LIMIT_PER_HOUR = _env_int(
    "SUPPORT_TICKET_RATE_LIMIT_PER_HOUR", 25
)
SUPPORT_FEEDBACK_TOKEN_TTL_HOURS = _env_int(
    "SUPPORT_FEEDBACK_TOKEN_TTL_HOURS", 24 * 14
)


class AttachmentRef(BaseModel):
    object_id: str = Field(..., min_length=1)
    filename: Optional[str] = None
    content_type: str = "image/png"
    size_bytes: Optional[int] = None
    get_url: Optional[str] = None


class SupportTicketCreate(BaseModel):
    type: Literal["support", "feature"] = "support"
    category: str = Field(..., min_length=2, max_length=64)
    severity: Optional[Literal["critical", "high", "medium", "low"]] = None
    summary: str = Field(..., min_length=8, max_length=120)
    description: str = Field(..., min_length=20)
    job_id: Optional[str] = None
    job_verified: Optional[bool] = None
    doc_id: Optional[str] = None
    source: Optional[
        Literal["gsheets_addon", "web", "api", "email", "gremlin"]
    ] = None
    context_json: Optional[Dict[str, Any]] = None
    attachments: Optional[List[AttachmentRef]] = Field(default=None, max_length=5)

    @field_validator("severity")
    @classmethod
    def severity_required_for_support(cls, value: Optional[str], info: Any) -> Optional[str]:
        ticket_type = (info.data or {}).get("type")
        if ticket_type == "support" and value is None:
            raise ValueError("severity required for support tickets")
        return value


class SupportTicketUpdate(BaseModel):
    status: Optional[TicketStatus] = None
    severity: Optional[TicketSeverity] = None
    category: Optional[str] = None
    resolution: Optional[str] = None


class NoteCreate(BaseModel):
    content: str = Field(..., min_length=1)
    is_internal: bool = True


class SupportEmailSend(BaseModel):
    body: str = Field(..., min_length=1)
    subject_override: Optional[str] = None
    to_email: Optional[str] = None


class TriageMode(str, enum.Enum):
    FULL = "full"
    REPLY_ONLY = "reply_only"


class AITriageRequest(BaseModel):
    mode: TriageMode = TriageMode.FULL
    force: bool = False


class CustomerNotificationError(RuntimeError):
    def __init__(self, notification_type: str, stage: str, message: str) -> None:
        super().__init__(message)
        self.notification_type = notification_type
        self.stage = stage
        self.message = message


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _to_snippet(text: Optional[str], limit: int = 140) -> Optional[str]:
    if not text:
        return None
    return text.strip()[:limit]


def _json_size(value: Any) -> int:
    try:
        return len(json.dumps(value, default=str).encode("utf-8"))
    except TypeError:
        return len(json.dumps(str(value)).encode("utf-8"))


def _truncate_context_json(
    context: Optional[Dict[str, Any]],
    max_bytes: int = 65536,
    preserve_keys: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
    if context is None:
        return None

    safe = dict(context)
    preserve = set(preserve_keys or [])
    if _json_size(safe) <= max_bytes:
        return safe

    for key in ("debug_bundle", "activity_log", "diagnostics", "diagnostics_snapshot", "sheet_snapshot"):
        if key in safe and key not in preserve:
            safe[key] = None
            if _json_size(safe) <= max_bytes:
                safe["truncated"] = True
                return safe

    truncated = {"truncated": True, "reason": "payload_too_large"}
    for key in preserve:
        if key in context:
            truncated[key] = context[key]
    if _json_size(truncated) <= max_bytes:
        return truncated
    return {"truncated": True, "reason": "payload_too_large"}


async def _build_account_snapshot(
    db: AsyncSession, account_id: Optional[str]
) -> Optional[Dict[str, Any]]:
    if not account_id:
        return None
    account = await db.get(Account, account_id)
    quota = await db.get(UsageQuotaModel, account_id)

    snapshot: Dict[str, Any] = {
        "captured_at": datetime.utcnow().isoformat(),
        "account": {
            "id": str(account_id),
            "name": account.name if account else None,
        },
    }

    if quota:
        snapshot["usage"] = {
            "tier": quota.tier,
            "subscription_status": quota.subscription_status,
            "monthly_rows_used": quota.monthly_rows_used,
            "monthly_rows_limit": quota.monthly_match_entitlement,
            "enrichment_credits_used": quota.enrichment_credits_used,
            "enrichment_credits_limit": quota.monthly_enrichment_entitlement,
            "daily_rows_used": quota.daily_rows_used,
            "daily_runs_used": quota.daily_runs_used,
            "active_schedules": quota.active_schedules,
            "grace_uses_remaining": quota.grace_uses_remaining,
            "tier_synced_at": quota.tier_synced_at.isoformat() if quota.tier_synced_at else None,
            "usage_updated_at": quota.updated_at.isoformat() if quota.updated_at else None,
            "reset_at": quota.reset_at.isoformat() if quota.reset_at else None,
            "tier_sync_source": quota.tier_sync_source,
        }

    return snapshot


def _generate_public_id() -> str:
    token = secrets.token_hex(2).upper()
    year = datetime.utcnow().strftime("%y")
    return f"SUP-{token}-{year}"


def _compute_identity_key(account_id: Optional[str], user_email: str) -> str:
    if account_id:
        return str(account_id)
    normalized = (user_email or "").strip().lower()
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]


def _get_secret() -> str:
    secret = (
        getattr(settings, "SECRET_KEY", None)
        or getattr(settings, "WEBHOOK_HMAC_SECRET", None)
        or getattr(settings, "JWT_SECRET_KEY", None)
    )
    return secret or "dev-secret"


def _make_token(payload: dict, secret: str) -> str:
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return base64.urlsafe_b64encode(body).decode() + "." + sig


def _maybe_make_absolute(url: str) -> str:
    base = os.getenv("PUBLIC_BASE_URL") or getattr(settings, "PUBLIC_BASE_URL", "")
    if not url or url.startswith("http://") or url.startswith("https://"):
        return url
    if not base:
        return url
    base = str(base).rstrip("/")
    normalized = url if str(url).startswith("/") else f"/{url}"
    return f"{base}{normalized}"


def _parse_signed_token(token: str, secret: str) -> Dict[str, Any]:
    if not token or "." not in token:
        raise ValueError("Invalid token.")

    encoded_payload, provided_sig = token.rsplit(".", 1)
    padding = "=" * (-len(encoded_payload) % 4)
    try:
        payload_bytes = base64.urlsafe_b64decode(f"{encoded_payload}{padding}".encode("utf-8"))
        payload = json.loads(payload_bytes.decode("utf-8"))
    except Exception as exc:  # pragma: no cover - defensive decode path
        raise ValueError("Invalid token.") from exc

    expected_sig = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected_sig, provided_sig):
        raise ValueError("Invalid token.")

    return payload


def _verify_feedback_token(token: str, public_id: str, response: str) -> Dict[str, Any]:
    payload = _parse_signed_token(token, _get_secret())
    if payload.get("kind") != "support_feedback":
        raise ValueError("Invalid feedback link.")
    if payload.get("public_id") != public_id or payload.get("response") != response:
        raise ValueError("Invalid feedback link.")

    expires_at = payload.get("exp")
    if expires_at is not None:
        try:
            expires_ts = float(expires_at)
        except (TypeError, ValueError) as exc:
            raise ValueError("Invalid feedback link.") from exc
        if datetime.now(timezone.utc).timestamp() > expires_ts:
            raise ValueError("This feedback link has expired.")

    return payload


def _build_feedback_link(ticket: SupportTicket, response: str) -> str:
    expires_at = datetime.now(timezone.utc) + timedelta(hours=SUPPORT_FEEDBACK_TOKEN_TTL_HOURS)
    token = _make_token(
        {
            "kind": "support_feedback",
            "public_id": ticket.public_id,
            "response": response,
            "nonce": uuid4().hex,
            "exp": int(expires_at.timestamp()),
        },
        _get_secret(),
    )
    query = urlencode({"response": response, "token": token})
    return _maybe_make_absolute(f"/api/v2/support/tickets/{ticket.public_id}/feedback?{query}")


async def _generate_unique_public_id(db: AsyncSession, attempts: int = 8) -> str:
    for _ in range(attempts):
        public_id = _generate_public_id()
        existing = await db.execute(
            select(SupportTicket.id).where(SupportTicket.public_id == public_id)
        )
        if not existing.scalar_one_or_none():
            return public_id
    raise HTTPException(500, "Failed to generate support ticket ID")


async def _ensure_account(account_id: Optional[str], db: AsyncSession) -> None:
    if not account_id:
        return
    account = await db.get(Account, account_id)
    if account:
        return
    account = Account(id=account_id, name=f"Clerk Org {account_id}")
    db.add(account)
    await db.flush()


def _serialize_ticket(ticket: SupportTicket) -> Dict[str, Any]:
    return {
        "id": ticket.id,
        "public_id": ticket.public_id,
        "type": ticket.type.value if ticket.type else None,
        "category": ticket.category,
        "severity": ticket.severity.value if ticket.severity else None,
        "status": ticket.status.value if ticket.status else None,
        "source": ticket.source.value if ticket.source else None,
        "summary": ticket.summary,
        "description": ticket.description,
        "description_snippet": ticket.description_snippet,
        "job_id": ticket.job_id,
        "job_verified": ticket.job_verified,
        "doc_id": ticket.doc_id,
        "context_json": ticket.context_json,
        "gmail_thread_id": ticket.gmail_thread_id,
        "resolution": ticket.resolution,
        "resolved_by": ticket.resolved_by,
        "created_at": ticket.created_at.isoformat() if ticket.created_at else None,
        "updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
        "resolved_at": ticket.resolved_at.isoformat() if ticket.resolved_at else None,
        "user_email": ticket.user_email,
        "account_id": ticket.account_id,
    }


def _serialize_note(note: SupportTicketNote) -> Dict[str, Any]:
    return {
        "id": note.id,
        "ticket_id": note.ticket_id,
        "author_email": note.author_email,
        "content": note.content,
        "gmail_message_id": note.gmail_message_id,
        "rfc822_message_id": note.rfc822_message_id,
        "gmail_thread_id": note.gmail_thread_id,
        "direction": note.direction.value if note.direction else None,
        "is_internal": note.is_internal,
        "created_at": note.created_at.isoformat() if note.created_at else None,
    }


def _format_email_subject(
    public_id: str, subject_text: str, is_reply: bool
) -> str:
    base = (subject_text or "").strip() or "Support request"
    token = f"[{public_id}]"
    if token not in base:
        base = f"{token} {base}".strip()
    if is_reply and not base.lower().startswith("re:"):
        base = f"Re: {base}"
    return base


def _support_sender_email() -> str:
    return (
        (os.getenv("GMAIL_SUPPORT_EMAIL") or getattr(settings, "SUPPORT_EMAIL", "")).strip()
        or "support@foundrymatch.com"
    )


def _build_resolution_subject(ticket: SupportTicket) -> str:
    subject_text = ticket.summary or "Support request resolved"
    return _format_email_subject(ticket.public_id, f"Resolved: {subject_text}", bool(ticket.gmail_thread_id))


def _build_resolution_body(ticket: SupportTicket) -> str:
    yes_url = _build_feedback_link(ticket, "yes")
    no_url = _build_feedback_link(ticket, "no")
    lines = [
        "Hi,",
        "",
        f"We believe we've resolved ticket {ticket.public_id}.",
    ]
    if ticket.resolution:
        lines.extend(
            [
                "",
                "Resolution summary:",
                ticket.resolution.strip(),
            ]
        )
    lines.extend(
        [
            "",
            "Did this resolve the issue?",
            f"Yes: {yes_url}",
            f"No: {no_url}",
            "",
            "You can also reply to this email with more details or screenshots if anything still looks off.",
            "",
            "Best,",
            "FoundryMatch Support",
        ]
    )
    return "\n".join(lines)


def _build_reopened_subject(ticket: SupportTicket) -> str:
    subject_text = ticket.summary or "Support request reopened"
    return _format_email_subject(ticket.public_id, f"Ticket reopened: {subject_text}", bool(ticket.gmail_thread_id))


def _build_reopened_body(ticket: SupportTicket, reason: Optional[str] = None) -> str:
    lines = [
        "Hi,",
        "",
        f"We reopened ticket {ticket.public_id} and will keep working on it.",
    ]
    if reason:
        lines.extend(["", reason.strip()])
    lines.extend(
        [
            "",
            "Reply to this email with any extra details that would help us continue the investigation.",
            "",
            "Best,",
            "FoundryMatch Support",
        ]
    )
    return "\n".join(lines)


def _feedback_note_content(response: str) -> str:
    if response == "yes":
        return "Customer confirmed this issue is resolved via the email feedback link."
    return "Customer indicated this issue is still open via the email feedback link."


def _update_customer_notification_state(
    ticket: SupportTicket,
    *,
    notification_type: str,
    status: str,
    error: Optional[str] = None,
    stage: Optional[str] = None,
) -> Dict[str, Any]:
    context = dict(ticket.context_json or {})
    notification = context.get("customer_notification")
    if not isinstance(notification, dict):
        notification = {}

    now_iso = _now_iso()
    notification["last_attempted_at"] = now_iso
    notification["last_type"] = notification_type
    notification["last_status"] = status

    if status == "sent":
        notification["last_sent_at"] = now_iso
        notification.pop("failed_at", None)
        notification.pop("failed_type", None)
        notification.pop("failed_error", None)
        notification.pop("failed_stage", None)
    else:
        notification["failed_at"] = now_iso
        notification["failed_type"] = notification_type
        notification["failed_error"] = (error or "Unknown error").strip()[:500]
        if stage:
            notification["failed_stage"] = stage
        else:
            notification.pop("failed_stage", None)

    context["customer_notification"] = notification
    ticket.context_json = context
    return notification


async def _persist_customer_notification_failure(
    db: AsyncSession,
    ticket: SupportTicket,
    *,
    notification_type: str,
    error: str,
    stage: str,
) -> None:
    _update_customer_notification_state(
        ticket,
        notification_type=notification_type,
        status="failed",
        error=error,
        stage=stage,
    )
    db.add(ticket)
    try:
        await db.commit()
        await db.refresh(ticket)
    except Exception as persist_exc:
        await db.rollback()
        log.warning(
            "Failed to persist customer notification failure state for %s (%s/%s): %s",
            ticket.public_id,
            notification_type,
            stage,
            persist_exc,
        )


def _get_failed_customer_notification_type(ticket: SupportTicket) -> Optional[str]:
    context = ticket.context_json or {}
    if not isinstance(context, dict):
        return None
    notification = context.get("customer_notification")
    if not isinstance(notification, dict):
        return None
    if notification.get("last_status") != "failed":
        return None
    failed_type = notification.get("failed_type") or notification.get("last_type")
    return str(failed_type) if failed_type else None


def _get_customer_notification_stage(ticket: SupportTicket) -> Optional[str]:
    context = ticket.context_json or {}
    if not isinstance(context, dict):
        return None
    notification = context.get("customer_notification")
    if not isinstance(notification, dict):
        return None
    stage = notification.get("failed_stage")
    return str(stage) if stage else None


def _record_resolution_feedback(
    ticket: SupportTicket,
    response: str,
    nonce: Optional[str],
) -> bool:
    context = dict(ticket.context_json or {})
    feedback_context = context.get("resolution_feedback")
    if not isinstance(feedback_context, dict):
        feedback_context = {}

    used_nonces = feedback_context.get("used_nonces")
    if not isinstance(used_nonces, list):
        used_nonces = []
    used_nonces = [str(item) for item in used_nonces if item]

    if nonce and nonce in used_nonces:
        return False

    if nonce:
        used_nonces = (used_nonces + [nonce])[-20:]

    events = feedback_context.get("events")
    if not isinstance(events, list):
        events = []

    recorded_at = _now_iso()
    event = {
        "response": response,
        "recorded_at": recorded_at,
        "source": "email_link",
    }
    if nonce:
        event["nonce"] = nonce

    feedback_context["used_nonces"] = used_nonces
    feedback_context["events"] = (events + [event])[-10:]
    feedback_context["last_response"] = response
    feedback_context["last_recorded_at"] = recorded_at

    context["resolution_feedback"] = feedback_context
    ticket.context_json = context
    return True


def _feedback_page(title: str, message: str) -> HTMLResponse:
    safe_title = escape(title or "Support feedback")
    safe_message = escape(message or "")
    html = f"""<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{safe_title}</title>
    <style>
      body {{
        margin: 0;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
        color: #0f172a;
      }}
      main {{
        max-width: 560px;
        margin: 10vh auto;
        background: white;
        border: 1px solid #cbd5e1;
        border-radius: 18px;
        padding: 32px;
        box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
      }}
      h1 {{
        font-size: 1.4rem;
        margin: 0 0 12px;
      }}
      p {{
        margin: 0;
        line-height: 1.6;
        color: #334155;
      }}
    </style>
  </head>
  <body>
    <main>
      <h1>{safe_title}</h1>
      <p>{safe_message}</p>
    </main>
  </body>
</html>"""
    return HTMLResponse(content=html, status_code=200)


def _build_acknowledgment_body(ticket: SupportTicket) -> str:
    is_feature_request = ticket.type == TicketType.FEATURE
    summary = (ticket.summary or "").strip()
    lines = [
        "Hi,",
        "",
        (
            "Thanks for sharing your feature request."
            if is_feature_request
            else "Thanks for contacting FoundryMatch Support."
        ),
        (
            f"We recorded it under ticket {ticket.public_id} and will review it shortly."
            if is_feature_request
            else f"We received your request and opened ticket {ticket.public_id}."
        ),
    ]
    if summary:
        lines.extend(["", f"Summary: {summary}"])
    lines.extend(
        [
            "",
            "Reply to this email to add more details or screenshots.",
            f"Please keep [{ticket.public_id}] in the subject so we can track the thread.",
            "",
            "Best,",
            "FoundryMatch Support",
        ]
    )
    return "\n".join(lines)


def _build_acknowledgment_subject(ticket: SupportTicket) -> str:
    if ticket.type == TicketType.FEATURE:
        subject_text = ticket.summary or "Feature request received"
    else:
        subject_text = ticket.summary or "Support request received"
    return _format_email_subject(ticket.public_id, subject_text, False)


async def _send_ticket_acknowledgment(
    db: AsyncSession,
    ticket: SupportTicket,
) -> bool:
    to_email = (ticket.user_email or "").strip()
    if "@" not in to_email:
        return False

    subject = _build_acknowledgment_subject(ticket)
    body = _build_acknowledgment_body(ticket)
    await _send_ticket_email_note(
        db,
        ticket,
        subject=subject,
        body=body,
        to_email=to_email,
        author_email=_support_sender_email(),
        notification_type="acknowledgment",
    )
    return True


async def _send_ticket_resolution_email(
    db: AsyncSession,
    ticket: SupportTicket,
) -> bool:
    subject = _build_resolution_subject(ticket)
    body = _build_resolution_body(ticket)
    note = await _send_ticket_email_note(
        db,
        ticket,
        subject=subject,
        body=body,
        author_email=_support_sender_email(),
        notification_type="resolution",
    )
    return note is not None


async def _send_ticket_reopened_email(
    db: AsyncSession,
    ticket: SupportTicket,
    *,
    reason: Optional[str] = None,
) -> bool:
    subject = _build_reopened_subject(ticket)
    body = _build_reopened_body(ticket, reason=reason)
    note = await _send_ticket_email_note(
        db,
        ticket,
        subject=subject,
        body=body,
        author_email=_support_sender_email(),
        notification_type="reopened",
    )
    return note is not None


async def _retry_failed_customer_notification(
    db: AsyncSession,
    ticket: SupportTicket,
) -> Optional[str]:
    notification_type = _get_failed_customer_notification_type(ticket)
    if not notification_type:
        return None
    if _get_customer_notification_stage(ticket) == "persist":
        raise HTTPException(
            409,
            "This notification reached the post-send persistence stage. Review the Gmail thread before sending again because the customer may have already received it.",
        )

    if notification_type == "acknowledgment":
        await _send_ticket_acknowledgment(db, ticket)
        return notification_type

    if notification_type == "resolution":
        if ticket.status != TicketStatus.RESOLVED:
            raise HTTPException(
                409,
                "This ticket is no longer resolved, so the resolution email cannot be retried.",
            )
        await _send_ticket_resolution_email(db, ticket)
        return notification_type

    if notification_type == "reopened":
        if ticket.status not in {
            TicketStatus.OPEN,
            TicketStatus.IN_PROGRESS,
            TicketStatus.WAITING_ON_CUSTOMER,
        }:
            raise HTTPException(
                409,
                "This ticket is no longer in an active state, so the reopen email cannot be retried.",
            )
        await _send_ticket_reopened_email(db, ticket)
        return notification_type

    raise HTTPException(400, f"Unsupported notification type: {notification_type}")


async def _enforce_support_ticket_rate_limit(
    db: AsyncSession,
    account_id: str,
) -> None:
    limit = SUPPORT_TICKET_RATE_LIMIT_PER_HOUR
    if limit <= 0:
        return

    window_start = datetime.now(timezone.utc) - timedelta(hours=1)
    stmt = select(func.count(SupportTicket.id)).where(
        SupportTicket.account_id == str(account_id),
        SupportTicket.created_at >= window_start,
    )
    recent_count = (await db.execute(stmt)).scalar_one()
    if recent_count >= limit:
        raise HTTPException(
            429,
            f"Support ticket limit reached for this account. Max {limit} per hour.",
        )


def _collect_thread_headers(
    notes: List[SupportTicketNote],
) -> tuple[Optional[str], List[str]]:
    references: List[str] = []
    seen = set()
    for note in notes:
        if not note.rfc822_message_id:
            continue
        value = note.rfc822_message_id.strip()
        if value and value not in seen:
            references.append(value)
            seen.add(value)

    inbound_reply = None
    outbound_reply = None
    for note in reversed(notes):
        if not note.rfc822_message_id:
            continue
        if note.direction == TicketNoteDirection.INBOUND:
            inbound_reply = note.rfc822_message_id
            break
        if note.direction == TicketNoteDirection.OUTBOUND and outbound_reply is None:
            outbound_reply = note.rfc822_message_id

    return inbound_reply or outbound_reply, references


async def _send_ticket_email_note(
    db: AsyncSession,
    ticket: SupportTicket,
    *,
    subject: str,
    body: str,
    to_email: Optional[str] = None,
    author_email: Optional[str] = None,
    notification_type: Optional[str] = None,
) -> Optional[SupportTicketNote]:
    recipient = (to_email or ticket.user_email or "").strip()
    if "@" not in recipient:
        if notification_type:
            await _persist_customer_notification_failure(
                db,
                ticket,
                notification_type=notification_type,
                error="Ticket has no valid customer email address.",
                stage="validation",
            )
            raise CustomerNotificationError(
                notification_type,
                "validation",
                "Ticket has no valid customer email address.",
            )
        return None

    notes_stmt = (
        select(SupportTicketNote)
        .where(SupportTicketNote.ticket_id == ticket.id)
        .order_by(SupportTicketNote.created_at.asc())
    )
    notes = (await db.execute(notes_stmt)).scalars().all()
    in_reply_to, references = _collect_thread_headers(notes)

    try:
        gmail = await GmailSupportService.from_secret(db)
        if ticket.gmail_thread_id:
            send_result = await gmail.send_reply(
                to_email=recipient,
                ticket_public_id=ticket.public_id,
                subject=subject,
                body=body,
                gmail_thread_id=ticket.gmail_thread_id,
                in_reply_to=in_reply_to,
                references=references,
            )
        else:
            send_result = await gmail.send_new_ticket_email(
                to_email=recipient,
                ticket_public_id=ticket.public_id,
                subject=subject,
                body=body,
            )
    except Exception as exc:
        if notification_type:
            await _persist_customer_notification_failure(
                db,
                ticket,
                notification_type=notification_type,
                error=str(exc),
                stage="send",
            )
            raise CustomerNotificationError(notification_type, "send", str(exc)) from exc
        raise

    gmail_thread_id = send_result.get("gmail_thread_id")
    if gmail_thread_id and ticket.gmail_thread_id != gmail_thread_id:
        ticket.gmail_thread_id = gmail_thread_id
        db.add(ticket)

    note_content = (body or "").strip()
    if subject:
        note_content = f"Subject: {subject}\n\n{note_content}" if note_content else f"Subject: {subject}"

    note = SupportTicketNote(
        ticket_id=ticket.id,
        author_email=(author_email or gmail.support_email or _support_sender_email()),
        content=note_content,
        is_internal=False,
        direction=TicketNoteDirection.OUTBOUND,
        gmail_message_id=send_result.get("gmail_message_id"),
        rfc822_message_id=send_result.get("rfc822_message_id"),
        gmail_thread_id=gmail_thread_id,
    )
    db.add(note)
    if notification_type:
        _update_customer_notification_state(
            ticket,
            notification_type=notification_type,
            status="sent",
        )
        db.add(ticket)

    try:
        await db.commit()
    except Exception as exc:
        await db.rollback()
        if notification_type:
            await _persist_customer_notification_failure(
                db,
                ticket,
                notification_type=notification_type,
                error=str(exc),
                stage="persist",
            )
            raise CustomerNotificationError(notification_type, "persist", str(exc)) from exc
        raise

    await db.refresh(ticket)
    await db.refresh(note)
    return note


async def _load_ticket_context(
    db: AsyncSession, ticket: SupportTicket
) -> tuple[Optional[Account], Optional[UsageQuotaModel], List[Dict[str, Any]]]:
    account = None
    quota = None
    jobs: List[Dict[str, Any]] = []

    if ticket.account_id:
        account = await db.get(Account, ticket.account_id)
        quota = await db.get(UsageQuotaModel, ticket.account_id)

        job_stmt = (
            select(Job)
            .where(Job.account_id == ticket.account_id)
            .order_by(Job.created_at.desc())
            .limit(5)
        )
        job_rows = (await db.execute(job_stmt)).scalars().all()
        jobs = [
            {
                "id": row.id,
                "status": row.status.value if row.status else None,
                "job_type": row.job_type.value if row.job_type else None,
                "created_at": row.created_at.isoformat() if row.created_at else None,
                "updated_at": row.updated_at.isoformat() if row.updated_at else None,
            }
            for row in job_rows
        ]

    return account, quota, jobs


@router.post("/tickets/attach/presign")
async def presign_attachment(
    content_type: str = Query(default="image/png"),
    filename: Optional[str] = Query(default=None),
    identity: Dict[str, Any] = Depends(get_current_identity),
) -> Dict[str, Any]:
    if content_type not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(400, f"Unsupported content type: {content_type}")

    account_id = identity.get("org_id")
    user_email = identity.get("email") or "unknown"
    if not account_id:
        raise HTTPException(401, "No organization selected on token")

    identity_key = _compute_identity_key(str(account_id), user_email)
    ext = content_type.split("/")[-1]
    object_id = f"support-attachments/{identity_key}/{uuid4()}.{ext}"

    storage = get_storage_backend()
    if settings.STORAGE_BACKEND != "local":
        try:
            put_url = await storage.generate_presigned_url(
                object_id, operation="put_object"
            )
            get_url = await storage.generate_presigned_url(
                object_id, operation="get_object"
            )
        except Exception as exc:
            raise HTTPException(500, f"presign_failed: {exc}")

        return {
            "ok": True,
            "data": {"object_id": object_id, "put_url": put_url, "get_url": get_url},
        }

    ttl = int(os.getenv("SIGNED_URL_TTL_SEC", "86400"))
    token = _make_token(
        {
            "key": object_id,
            "ct": content_type,
            "acct": str(account_id),
            "exp": int((datetime.utcnow() + timedelta(seconds=ttl)).timestamp()),
        },
        _get_secret(),
    )
    put_url = _maybe_make_absolute(f"/api/v2/uploads/put/{object_id}?token={token}")

    try:
        get_url = await storage.generate_presigned_url(
            object_id, operation="get_object"
        )
    except Exception:
        get_url = f"/api/v1/storage/download/{object_id}?token={token}"

    return {
        "ok": True,
        "data": {
            "object_id": object_id,
            "put_url": put_url,
            "get_url": _maybe_make_absolute(get_url),
        },
    }


@router.post("/tickets")
async def create_ticket(
    ticket: SupportTicketCreate,
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
    identity: Dict[str, Any] = Depends(get_current_identity),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    account_id = identity.get("org_id")
    user_email = identity.get("email") or "unknown"

    if not account_id:
        raise HTTPException(401, "No organization selected on token")

    identity_key = _compute_identity_key(str(account_id), user_email)

    await _ensure_account(account_id, db)

    if idempotency_key:
        existing = await db.execute(
            select(SupportTicket).where(
                SupportTicket.identity_key == identity_key,
                SupportTicket.idempotency_key == idempotency_key,
            )
        )
        ticket_row = existing.scalar_one_or_none()
        if ticket_row:
            return {
                "ok": True,
                "data": {
                    "id": ticket_row.id,
                    "public_id": ticket_row.public_id,
                    "status": "exists",
                },
            }

    await _enforce_support_ticket_rate_limit(db, str(account_id))

    attachments_payload: Optional[List[Dict[str, Any]]] = None
    if ticket.attachments:
        if len(ticket.attachments) > MAX_ATTACHMENTS_PER_TICKET:
            raise HTTPException(
                400, f"Max {MAX_ATTACHMENTS_PER_TICKET} attachments allowed"
            )
        attachments_payload = []
        for attachment in ticket.attachments:
            if attachment.content_type not in ALLOWED_IMAGE_TYPES:
                raise HTTPException(
                    400, f"Unsupported content type: {attachment.content_type}"
                )
            if attachment.size_bytes and attachment.size_bytes > MAX_ATTACHMENT_SIZE:
                raise HTTPException(400, "Attachment exceeds 10MB limit")
            if not attachment.object_id.startswith(
                f"support-attachments/{identity_key}/"
            ):
                raise HTTPException(403, "Invalid attachment ownership")
            attachments_payload.append(attachment.model_dump())

    context_json = dict(ticket.context_json or {})
    if attachments_payload:
        context_json["attachments"] = attachments_payload
    snapshot = await _build_account_snapshot(db, str(account_id))
    if snapshot:
        context_json["server_snapshot"] = snapshot
    context_json = _truncate_context_json(
        context_json,
        preserve_keys=["attachments", "server_snapshot"],
    )
    description_snippet = _to_snippet(ticket.description)
    public_id = await _generate_unique_public_id(db)

    record = SupportTicket(
        public_id=public_id,
        idempotency_key=idempotency_key or None,
        identity_key=identity_key,
        account_id=str(account_id),
        user_email=user_email,
        type=TicketType(ticket.type),
        category=ticket.category,
        severity=TicketSeverity(ticket.severity) if ticket.severity else None,
        status=TicketStatus.OPEN,
        source=TicketSource(ticket.source)
        if ticket.source
        else TicketSource.GSHEETS_ADDON,
        summary=ticket.summary.strip(),
        description=ticket.description,
        description_snippet=description_snippet,
        job_id=ticket.job_id,
        job_verified=bool(ticket.job_verified) if ticket.job_verified is not None else False,
        doc_id=ticket.doc_id,
        context_json=context_json,
    )

    db.add(record)
    await db.flush()

    try:
        sheet_id = (context_json or {}).get("sheet_id") or record.doc_id
        telemetry_bundle = await enrich_ticket_with_telemetry(
            db=db,
            org_id=str(account_id),
            job_id=record.job_id,
            sheet_id=sheet_id,
            ticket_created_at=record.created_at or datetime.now(timezone.utc),
            context_json=context_json,
        )
        record.context_json = {
            **(record.context_json or {}),
            "telemetry_bundle": telemetry_bundle,
        }
    except Exception as exc:
        log.warning("Telemetry enrichment failed: %s", exc)
        record.context_json = {
            **(record.context_json or {}),
            "telemetry_bundle": {"error": "telemetry_lookup_failed"},
        }

    await db.commit()
    await db.refresh(record)

    try:
        await _send_ticket_acknowledgment(db, record)
    except Exception as exc:
        log.warning(
            "Failed to send support ticket acknowledgment for %s: %s",
            record.public_id,
            exc,
        )

    try:
        await notify_support_ticket_created(
            public_id=record.public_id,
            account_id=record.account_id,
            user_email=record.user_email,
            ticket_type=record.type.value if record.type else None,
            category=record.category,
            severity=record.severity.value if record.severity else None,
            source=record.source.value if record.source else None,
            summary=record.summary,
            description_snippet=record.description_snippet or record.description,
            created_at=record.created_at,
        )
    except Exception as exc:
        log.warning("Failed to send support ticket admin alert: %s", exc)

    return {
        "ok": True,
        "data": {
            "id": record.id,
            "public_id": record.public_id,
            "status": "created",
        },
    }


@router.get("/tickets/{public_id}")
async def get_ticket_status(
    public_id: str,
    identity: Dict[str, Any] = Depends(get_current_identity),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    account_id = identity.get("org_id")
    if not account_id:
        raise HTTPException(401, "No organization selected on token")

    result = await db.execute(
        select(SupportTicket).where(
            SupportTicket.public_id == public_id,
            SupportTicket.account_id == account_id,
        )
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    return {"ok": True, "data": _serialize_ticket(ticket)}


@router.get("/tickets/{public_id}/feedback", include_in_schema=False)
async def record_ticket_feedback(
    public_id: str,
    response: Literal["yes", "no"] = Query(...),
    token: str = Query(..., min_length=8),
    db: AsyncSession = Depends(get_session),
) -> HTMLResponse:
    try:
        token_payload = _verify_feedback_token(token, public_id, response)
    except ValueError as exc:
        return _feedback_page("Feedback link unavailable", str(exc))

    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        return _feedback_page("Ticket not found", "This support ticket could not be located.")

    if not _record_resolution_feedback(ticket, response, token_payload.get("nonce")):
        if response == "yes":
            return _feedback_page(
                "Feedback already recorded",
                f"We already recorded that ticket {public_id} is resolved.",
            )
        return _feedback_page(
            "Feedback already recorded",
            f"We already reopened ticket {public_id}. Reply to the support thread if you have more details.",
        )

    note = SupportTicketNote(
        ticket_id=ticket.id,
        author_email=ticket.user_email,
        content=_feedback_note_content(response),
        is_internal=False,
        direction=TicketNoteDirection.INBOUND,
    )
    db.add(note)

    reopened = False
    if response == "no" and ticket.status == TicketStatus.RESOLVED:
        ticket.status = TicketStatus.OPEN
        ticket.resolved_at = None
        reopened = True

    db.add(ticket)
    try:
        await db.commit()
    except Exception:
        await db.rollback()
        raise
    await db.refresh(ticket)

    if reopened:
        try:
            await _send_ticket_reopened_email(
                db,
                ticket,
                reason="You let us know the issue is still open, so we moved the ticket back into the active queue.",
            )
        except Exception as exc:
            log.warning(
                "Failed to send support ticket reopen acknowledgment for %s: %s",
                ticket.public_id,
                exc,
            )
        return _feedback_page(
            "Ticket reopened",
            f"Thanks. We reopened ticket {public_id} and will follow up.",
        )

    return _feedback_page(
        "Feedback recorded",
        f"Thanks. We recorded your confirmation for ticket {public_id}.",
    )


@admin_router.get("")
async def list_tickets(
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
    status: Optional[TicketStatus] = Query(None),
    severity: Optional[TicketSeverity] = Query(None),
    type: Optional[TicketType] = Query(None),
    source: Optional[TicketSource] = Query(None),
    account_id: Optional[str] = Query(None),
    search: Optional[str] = Query(None),
    page: int = Query(1, ge=1),
    limit: int = Query(50, ge=1, le=100),
) -> Dict[str, Any]:
    # Build filters list to apply to both count and main query
    filters = []
    if status:
        filters.append(SupportTicket.status == status)
    if severity:
        filters.append(SupportTicket.severity == severity)
    if type:
        filters.append(SupportTicket.type == type)
    if source:
        filters.append(SupportTicket.source == source)
    if account_id:
        filters.append(SupportTicket.account_id == account_id)

    # Search filter needs join, so handle separately
    search_filter = None
    if search:
        like = f"%{search}%"
        search_filter = or_(
            SupportTicket.public_id.ilike(like),
            SupportTicket.user_email.ilike(like),
            SupportTicket.summary.ilike(like),
            Account.name.ilike(like),
        )

    # Count query - explicit select_from to avoid ambiguity
    count_stmt = (
        select(func.count(SupportTicket.id))
        .select_from(SupportTicket)
        .outerjoin(Account, SupportTicket.account_id == Account.id)
    )
    for f in filters:
        count_stmt = count_stmt.where(f)
    if search_filter is not None:
        count_stmt = count_stmt.where(search_filter)

    total = (await db.execute(count_stmt)).scalar_one()

    # Main query with all joins
    stmt = (
        select(SupportTicket, Account, UsageQuotaModel)
        .select_from(SupportTicket)
        .outerjoin(Account, SupportTicket.account_id == Account.id)
        .outerjoin(UsageQuotaModel, UsageQuotaModel.account_id == Account.id)
    )
    for f in filters:
        stmt = stmt.where(f)
    if search_filter is not None:
        stmt = stmt.where(search_filter)

    stmt = stmt.order_by(SupportTicket.created_at.desc())
    stmt = stmt.offset((page - 1) * limit).limit(limit)

    rows = (await db.execute(stmt)).all()
    tickets: List[Dict[str, Any]] = []
    for ticket, account, quota in rows:
        rows_used = quota.monthly_rows_used if quota else 0
        rows_limit = quota.monthly_match_entitlement if quota else 0
        usage_pct = None
        if rows_limit:
            usage_pct = round((rows_used / rows_limit) * 100, 1)

        tickets.append(
            {
                "public_id": ticket.public_id,
                "summary": ticket.summary,
                "description_snippet": ticket.description_snippet,
                "category": ticket.category,
                "severity": ticket.severity.value if ticket.severity else None,
                "status": ticket.status.value if ticket.status else None,
                "type": ticket.type.value if ticket.type else None,
                "source": ticket.source.value if ticket.source else None,
                "user_email": ticket.user_email,
                "account_id": ticket.account_id,
                "account_name": account.name if account else None,
                "tier": quota.tier if quota else None,
                "usage_percent": usage_pct,
                "created_at": ticket.created_at.isoformat() if ticket.created_at else None,
                "updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
            }
        )

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="list_support_tickets",
        db=db,
        details={
            "page": page,
            "limit": limit,
            "status": status.value if status else None,
            "severity": severity.value if severity else None,
            "type": type.value if type else None,
            "search": search,
        },
    )

    return {
        "ok": True,
        "data": {
            "tickets": tickets,
            "pagination": {
                "page": page,
                "limit": limit,
                "total": total,
                "pages": (total + limit - 1) // limit,
            },
        },
    }


@admin_router.get("/{public_id}")
async def get_ticket_detail(
    public_id: str,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    account = None
    quota = None
    jobs: List[Dict[str, Any]] = []
    if ticket.account_id:
        account = await db.get(Account, ticket.account_id)
        quota = await db.get(UsageQuotaModel, ticket.account_id)

        job_stmt = (
            select(Job)
            .where(Job.account_id == ticket.account_id)
            .order_by(Job.created_at.desc())
            .limit(5)
        )
        job_rows = (await db.execute(job_stmt)).scalars().all()
        jobs = [
            {
                "id": row.id,
                "status": row.status.value if row.status else None,
                "job_type": row.job_type.value if row.job_type else None,
                "created_at": row.created_at.isoformat() if row.created_at else None,
                "updated_at": row.updated_at.isoformat() if row.updated_at else None,
            }
            for row in job_rows
        ]

    notes_stmt = (
        select(SupportTicketNote)
        .where(SupportTicketNote.ticket_id == ticket.id)
        .order_by(SupportTicketNote.created_at.asc())
    )
    notes = (await db.execute(notes_stmt)).scalars().all()

    # Build response data BEFORE audit logging to avoid issues if audit
    # logging fails and triggers a rollback (which would detach objects)
    usage = None
    if quota:
        usage = {
            "tier": quota.tier,
            "status": quota.subscription_status,
            "monthly_rows_used": quota.monthly_rows_used,
            "monthly_rows_limit": quota.monthly_match_entitlement,
            "enrichment_credits_used": quota.enrichment_credits_used,
            "enrichment_credits_limit": quota.monthly_enrichment_entitlement,
        }

    account_data = None
    if account:
        account_data = {
            "id": account.id,
            "name": account.name,
            "stripe_customer_id": account.stripe_customer_id,
            "stripe_subscription_id": account.stripe_subscription_id,
            "data_region": account.data_region,
        }

    ticket_data = _serialize_ticket(ticket)
    notes_data = [_serialize_note(note) for note in notes]

    # Audit logging happens after response data is built
    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="view_support_ticket",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id},
    )

    return {
        "ok": True,
        "data": {
            "ticket": ticket_data,
            "account": account_data,
            "usage": usage,
            "recent_jobs": jobs,
            "notes": notes_data,
        },
    }


@admin_router.post("/{public_id}/ai-triage")
async def run_ai_triage(
    public_id: str,
    request: AITriageRequest,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    account, quota, jobs = await _load_ticket_context(db, ticket)

    try:
        service = SupportTriageService()
    except Exception as exc:
        log.exception("AI triage initialization failed: %s", exc)
        raise HTTPException(503, "AI triage unavailable")

    try:
        triage_result, context_hash, cached = await service.run_triage(
            db=db,
            ticket=ticket,
            account=account,
            usage=quota,
            recent_jobs=jobs,
            mode=request.mode.value,
            force=request.force,
        )
    except Exception as exc:
        message = str(exc).lower()
        if "reauthentication" in message or "application-default login" in message:
            log.warning("AI triage requires gcloud auth: %s", exc)
            raise HTTPException(
                503,
                "Vertex AI auth required. Run `gcloud auth application-default login`.",
            )
        if isinstance(exc, TimeoutError) or "timed out" in message:
            log.warning("AI triage timed out: %s", exc)
            raise HTTPException(
                504,
                "Vertex AI request timed out. Try again or use reply-only triage.",
            )
        log.exception("AI triage run failed: %s", exc)
        raise HTTPException(500, "AI triage failed")

    ticket.ai_triage_hash = context_hash
    ticket.ai_triage_last_run_at = datetime.now(timezone.utc)
    db.add(ticket)

    if not cached:
        note = SupportTicketNote(
            ticket_id=ticket.id,
            author_email="ai@foundryops.io",
            content=json.dumps(triage_result),
            is_internal=True,
            direction=TicketNoteDirection.INTERNAL,
        )
        db.add(note)

    await db.commit()

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="run_ai_triage",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id, "mode": request.mode.value, "force": request.force},
    )

    return {"ok": True, "data": triage_result, "cached": cached}


@admin_router.patch("/{public_id}")
async def update_ticket(
    public_id: str,
    payload: SupportTicketUpdate,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    previous_status = ticket.status
    should_send_resolution_email = False
    should_send_reopened_email = False

    if payload.status:
        should_send_resolution_email = (
            previous_status != TicketStatus.RESOLVED
            and payload.status == TicketStatus.RESOLVED
        )
        should_send_reopened_email = (
            previous_status == TicketStatus.RESOLVED
            and payload.status in {
                TicketStatus.OPEN,
                TicketStatus.IN_PROGRESS,
                TicketStatus.WAITING_ON_CUSTOMER,
            }
        )
        ticket.status = payload.status
        if payload.status == TicketStatus.RESOLVED:
            ticket.resolved_at = datetime.now(timezone.utc)
        elif payload.status != TicketStatus.RESOLVED:
            ticket.resolved_at = None

    if payload.severity is not None:
        ticket.severity = payload.severity

    if payload.category is not None:
        ticket.category = payload.category

    if payload.resolution is not None:
        ticket.resolution = payload.resolution
        ticket.resolved_by = admin_ctx.email or admin_ctx.user_id

    db.add(ticket)
    await db.commit()
    await db.refresh(ticket)

    if should_send_resolution_email:
        try:
            await _send_ticket_resolution_email(db, ticket)
        except Exception as exc:
            log.warning(
                "Failed to send support ticket resolution email for %s: %s",
                ticket.public_id,
                exc,
            )

    if should_send_reopened_email:
        try:
            await _send_ticket_reopened_email(db, ticket)
        except Exception as exc:
            log.warning(
                "Failed to send support ticket reopened email for %s: %s",
                ticket.public_id,
                exc,
            )

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="update_support_ticket",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id, "status": ticket.status.value},
    )

    return {"ok": True, "data": _serialize_ticket(ticket)}


@admin_router.post("/{public_id}/retry-notification")
async def retry_customer_notification(
    public_id: str,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    notification_type = _get_failed_customer_notification_type(ticket)
    if not notification_type:
        raise HTTPException(400, "No failed lifecycle notification is recorded for this ticket.")

    try:
        retried_type = await _retry_failed_customer_notification(db, ticket)
    except CustomerNotificationError as exc:
        log.warning(
            "Failed to retry support ticket notification for %s (%s/%s): %s",
            ticket.public_id,
            exc.notification_type,
            exc.stage,
            exc.message,
        )
        raise HTTPException(503, f"Retry failed: {exc.message}")

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="retry_support_ticket_notification",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id, "notification_type": retried_type or notification_type},
    )

    return {
        "ok": True,
        "data": {
            "ticket": _serialize_ticket(ticket),
            "notification_type": retried_type or notification_type,
        },
    }


@admin_router.post("/{public_id}/send-email")
async def send_support_email(
    public_id: str,
    payload: SupportEmailSend,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    to_email = (payload.to_email or ticket.user_email or "").strip()
    if not to_email:
        raise HTTPException(400, "Recipient email is required")

    subject_text = payload.subject_override or ticket.summary or "Support request"
    subject = _format_email_subject(ticket.public_id, subject_text, bool(ticket.gmail_thread_id))

    try:
        note = await _send_ticket_email_note(
            db,
            ticket,
            subject=subject,
            body=payload.body,
            to_email=to_email,
            author_email=admin_ctx.email or admin_ctx.user_id,
        )
    except Exception as exc:
        log.error("Support email send failed for %s: %s", ticket.public_id, exc)
        raise HTTPException(503, "Failed to send support email")

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="send_support_ticket_email",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id, "to_email": to_email},
    )

    return {
        "ok": True,
        "data": {
            "ticket": _serialize_ticket(ticket),
            "note": _serialize_note(note) if note else None,
            "gmail_thread_id": ticket.gmail_thread_id,
        },
    }


@admin_router.post("/{public_id}/notes")
async def add_note(
    public_id: str,
    payload: NoteCreate,
    admin_ctx: AdminContext = Depends(require_admin),
    db: AsyncSession = Depends(get_session),
) -> Dict[str, Any]:
    result = await db.execute(
        select(SupportTicket).where(SupportTicket.public_id == public_id)
    )
    ticket = result.scalar_one_or_none()
    if not ticket:
        raise HTTPException(404, "Ticket not found")

    note = SupportTicketNote(
        ticket_id=ticket.id,
        author_email=admin_ctx.email or admin_ctx.user_id,
        content=payload.content,
        is_internal=payload.is_internal,
        direction=TicketNoteDirection.INTERNAL,
    )
    db.add(note)
    await db.commit()
    await db.refresh(note)

    await log_admin_action(
        admin_user_id=admin_ctx.user_id,
        admin_email=admin_ctx.email,
        action="add_support_ticket_note",
        db=db,
        target_account_id=ticket.account_id,
        details={"public_id": public_id},
    )

    return {"ok": True, "data": _serialize_note(note)}
