"""notification.sms contract mapping around the live twilio.send_sms path.

Freeze input/output, map generic vs Twilio-only fields, resolve sender from the
connection, and select among Twilio candidates. The public capability may also
fall back to the P2HS-owned SMS service; provider details remain internal.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID

from src.domain.connectors.invoke_capabilities import (
    INVOCABLE_FALSE_MEANS,
    INVOKE_CAPABILITIES,
    NOTIFICATION_SMS_FORBIDDEN_METADATA_KEYS,
    NOTIFICATION_SMS_PROVIDER_INPUT_KEYS,
    TWILIO_NOTIFICATION_SMS_MAPPING,
    capability_surface,
)

CAPABILITY_ID = "messaging.sms.send"
_TWILIO_ACTION = "twilio.send_sms"
_E164 = re.compile(r"^\+[1-9]\d{1,14}$")
_PUBLIC_RESULT_KEYS = frozenset({"capability", "status", "message_id", "reason_code", "message"})
_PROVIDER_SHAPED_KEYS = frozenset({
    "to",
    "body",
    "from",
    "from_number",
    "media_url",
    "messaging_service_sid",
    "twilio_sid",
    "sid",
    "action",
    "connection_id",
    "request_type",
    "payload",
})


def _catalog_invocable() -> bool:
    return bool(capability_surface(CAPABILITY_ID, []).get("invocable"))


def normalize_e164(value: str) -> str | None:
    """Strip separators; require a leading + and ITU-T E.164 length."""
    raw = value.strip()
    if raw.lower().startswith("whatsapp:"):
        return None
    if raw.upper().startswith("MG"):
        return None
    compact = re.sub(r"[\s().-]", "", raw)
    if not _E164.match(compact):
        return None
    return compact


def map_notification_sms_to_twilio_input(*, destination: str, text: str) -> dict[str, str]:
    """Capability fields → existing twilio.send_sms generic input. No sender."""
    mapping = TWILIO_NOTIFICATION_SMS_MAPPING["fields"]
    return {
        mapping["destination"]: destination,
        mapping["text"]: text,
    }


def resolve_sender_from_connection_config(config: dict[str, Any]) -> dict[str, str] | None:
    """Sender is connection configuration, never capability input."""
    messaging_service_sid = config.get("messaging_service_sid")
    if isinstance(messaging_service_sid, str) and messaging_service_sid.strip():
        return {
            "source": "messaging_service_sid",
            "messaging_service_sid": messaging_service_sid.strip(),
        }
    from_phone = config.get("from_phone")
    if isinstance(from_phone, str) and from_phone.strip():
        return {"source": "from_phone", "from_phone": from_phone.strip()}
    return None


@dataclass(frozen=True)
class NotificationSmsConnectionSnapshot:
    connection_id: UUID
    connector_key: str
    status: str
    has_secrets: bool
    permission_mode: str
    config: dict[str, Any] = field(default_factory=dict)
    requires_secrets: bool = True
    client_side_only: bool = False
    action_slug: str = _TWILIO_ACTION


@dataclass(frozen=True)
class NotificationSmsPlan:
    capability: str
    connection_id: UUID
    action: str
    provider: str
    resolved_input: dict[str, Any]
    sender_source: str

    def integration_execute_payload(self) -> dict[str, Any]:
        return {
            "request_type": "integration.execute",
            "connection_id": str(self.connection_id),
            "action": self.action,
            "input": dict(self.resolved_input),
        }


@dataclass(frozen=True)
class NotificationSmsResolution:
    ok: bool
    reason_code: str | None = None
    message: str | None = None
    plan: NotificationSmsPlan | None = None
    diagnostics: dict[str, Any] = field(default_factory=dict)


def _fail(
    code: str, message: str, diagnostics: dict[str, Any] | None = None
) -> NotificationSmsResolution:
    return NotificationSmsResolution(
        ok=False, reason_code=code, message=message, diagnostics=diagnostics or {}
    )


@dataclass(frozen=True)
class NotificationSmsPublicResult:
    capability: str = CAPABILITY_ID
    status: str = "rejected"
    message_id: str | None = None
    reason_code: str | None = None
    message: str | None = None

    def as_dict(self) -> dict[str, Any]:
        body: dict[str, Any] = {
            "capability": self.capability,
            "status": self.status,
        }
        if self.message_id is not None:
            body["message_id"] = self.message_id
        if self.reason_code is not None:
            body["reason_code"] = self.reason_code
        if self.message is not None:
            body["message"] = self.message
        return body


def to_public_notification_sms_result(
    resolution: NotificationSmsResolution,
    *,
    enqueue: dict[str, Any] | None = None,
) -> NotificationSmsPublicResult:
    if not resolution.ok:
        return NotificationSmsPublicResult(
            status="rejected",
            reason_code=resolution.reason_code,
            message=resolution.message,
        )
    if enqueue is None:
        return NotificationSmsPublicResult(
            status="rejected",
            reason_code="provider_unavailable",
            message="notification.sms is not invocable; no enqueue path",
        )
    if not enqueue.get("ok"):
        return NotificationSmsPublicResult(
            status="rejected",
            reason_code=str(enqueue.get("reason_code") or "provider_unavailable"),
            message=str(enqueue.get("message") or "integration.execute enqueue failed"),
        )
    job_id = str(enqueue.get("job_id") or "").strip() or None
    return NotificationSmsPublicResult(status="queued", message_id=job_id)


def public_result_is_capability_shaped(body: dict[str, Any]) -> bool:
    keys = set(body)
    return keys <= _PUBLIC_RESULT_KEYS and not (keys & _PROVIDER_SHAPED_KEYS)


def _planned_action_for_connector(connector_key: str) -> str | None:
    mapping = TWILIO_NOTIFICATION_SMS_MAPPING
    if connector_key == "twilio" and mapping.get("provider_action"):
        return str(mapping["provider_action"])
    return None


def _forbidden_provider_fields(payload: dict[str, Any]) -> list[str]:
    return sorted(key for key in payload if key in NOTIFICATION_SMS_PROVIDER_INPUT_KEYS)


def resolve_notification_sms(
    *,
    payload: dict[str, Any],
    connections: list[NotificationSmsConnectionSnapshot],
    enabled_capabilities: set[str] | None = None,
    provider_hint: str | None = None,
    binding_connection_id: UUID | None = None,
    approval_present: bool = False,
    enforce_invocable: bool = True,
) -> NotificationSmsResolution:
    """Deterministic plan for notification.sms. Does not call Twilio."""
    diagnostics: dict[str, Any] = {"rejected": []}

    if CAPABILITY_ID not in INVOKE_CAPABILITIES:
        return _fail("capability_not_bound", "Unknown capability notification.sms")

    if enforce_invocable and not _catalog_invocable():
        return _fail("capability_not_invocable", INVOCABLE_FALSE_MEANS)

    leaked = _forbidden_provider_fields(payload)
    if leaked:
        return _fail(
            "provider_fields_forbidden",
            "notification.sms does not accept Twilio-specific fields; use twilio.send_sms",
            {"forbidden_fields": leaked},
        )
    metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
    stripped = sorted(set(metadata) & set(NOTIFICATION_SMS_FORBIDDEN_METADATA_KEYS))
    if stripped:
        diagnostics["stripped_metadata_keys"] = stripped

    text = payload.get("text")
    if not isinstance(text, str) or not text.strip():
        return _fail("text_required", "notification.sms requires text")
    destination_raw = payload.get("destination")
    if not isinstance(destination_raw, str) or not destination_raw.strip():
        return _fail("destination_invalid", "notification.sms requires an E.164 destination")
    destination = normalize_e164(destination_raw)
    if destination is None:
        return _fail(
            "destination_invalid",
            "destination must be an E.164 phone number; WhatsApp and SIDs use twilio.send_sms",
        )

    planned = [
        row for row in connections
        if _planned_action_for_connector(row.connector_key) and not row.client_side_only
    ]

    if binding_connection_id is not None:
        intended = next(
            (row for row in connections if row.connection_id == binding_connection_id),
            None,
        )
        if intended is None:
            return _fail(
                "capability_not_bound",
                "Explicit notification.sms binding does not match a project connection",
                diagnostics,
            )
        unique_fail = _unique_connection_gate(intended, enabled_capabilities, approval_present)
        if unique_fail:
            return unique_fail
        return _plan_for(intended, destination, text, diagnostics)

    eligible: list[NotificationSmsConnectionSnapshot] = []
    for row in planned:
        reason = _ineligibility_reason(row, enabled_capabilities, approval_present)
        if reason:
            diagnostics["rejected"].append(
                {
                    "connection_id": str(row.connection_id),
                    "connector_key": row.connector_key,
                    "reason": reason,
                }
            )
            continue
        eligible.append(row)

    hint = (provider_hint or "").strip().lower()
    if hint:
        eligible = [row for row in eligible if row.connector_key == hint]

    if not eligible:
        return _fail(
            "capability_not_bound",
            "No eligible notification.sms connection. Not messaging.send or email.send.",
            diagnostics,
        )
    if len(eligible) > 1:
        return _fail(
            "binding_ambiguous",
            "Multiple eligible notification.sms connections; set a project binding",
            {
                **diagnostics,
                "eligible": [
                    {
                        "connection_id": str(row.connection_id),
                        "connector_key": row.connector_key,
                    }
                    for row in eligible
                ],
            },
        )

    return _plan_for(eligible[0], destination, text, diagnostics)


def _unique_connection_gate(
    row: NotificationSmsConnectionSnapshot,
    enabled_capabilities: set[str] | None,
    approval_present: bool,
) -> NotificationSmsResolution | None:
    if row.status != "active":
        return _fail("connection_inactive", "Bound notification.sms connection is not active")
    if row.requires_secrets and not row.has_secrets:
        return _fail(
            "credentials_missing",
            "Bound notification.sms connection is missing credentials",
        )
    reason = _ineligibility_reason(row, enabled_capabilities, approval_present)
    if reason == "connection_inactive":
        return _fail("connection_inactive", "Bound notification.sms connection is not active")
    if reason == "credentials_missing":
        return _fail(
            "credentials_missing",
            "Bound notification.sms connection is missing credentials",
        )
    if reason:
        return _fail(
            "capability_not_bound",
            "Bound notification.sms connection is not eligible",
            {"rejected": [{"connection_id": str(row.connection_id), "reason": reason}]},
        )
    return None


def _ineligibility_reason(
    row: NotificationSmsConnectionSnapshot,
    enabled_capabilities: set[str] | None,
    approval_present: bool,
) -> str | None:
    if not _planned_action_for_connector(row.connector_key):
        return "adapter_not_planned"
    if row.client_side_only:
        return "client_side_only"
    if row.status != "active":
        return "connection_inactive"
    if row.requires_secrets and not row.has_secrets:
        return "credentials_missing"
    required = {"integrations.execute"}
    if required - (enabled_capabilities or set()):
        return "entitlement_missing"
    mode = (row.permission_mode or "deny").lower()
    if mode == "deny":
        return "permission_denied"
    if mode == "ask" and not approval_present:
        return "approval_required"
    return None


def _plan_for(
    row: NotificationSmsConnectionSnapshot,
    destination: str,
    text: str,
    diagnostics: dict[str, Any],
) -> NotificationSmsResolution:
    sender = resolve_sender_from_connection_config(row.config)
    if sender is None:
        return _fail(
            "sender_unbound",
            "Bound Twilio connection has no from_phone or messaging_service_sid",
            diagnostics,
        )
    action = _planned_action_for_connector(row.connector_key) or row.action_slug
    resolved = map_notification_sms_to_twilio_input(destination=destination, text=text)
    notes = {**diagnostics, "sender_source": sender["source"]}
    plan = NotificationSmsPlan(
        capability=CAPABILITY_ID,
        connection_id=row.connection_id,
        action=action,
        provider=row.connector_key,
        resolved_input=resolved,
        sender_source=sender["source"],
    )
    return NotificationSmsResolution(ok=True, plan=plan, diagnostics=notes)
