"""
Base telephony provider interface for abstracting telephony services.
This allows easy switching between different providers (Twilio, Vonage, etc.)
while keeping business logic decoupled from specific implementations.
"""

import random
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, List, Optional

if TYPE_CHECKING:
    from fastapi import WebSocket


@dataclass
class CallInitiationResult:
    """Standardized response from initiate_call across all providers."""

    call_id: str  # Provider's call identifier (SID for Twilio, UUID for Vonage)
    status: str  # Initial status (e.g., "queued", "initiated", "started")
    caller_number: Optional[str] = None  # Caller ID used for the outbound call
    provider_metadata: Dict[str, Any] = field(
        default_factory=dict
    )  # Data that needs to be persisted
    raw_response: Dict[str, Any] = field(
        default_factory=dict
    )  # Full provider response for debugging


@dataclass
class ProviderSyncResult:
    """Result of pushing a configuration change to the upstream provider.

    Used by ``configure_inbound`` (and similar provider-side syncs) so callers
    can surface a non-fatal warning to the user when the DB write succeeded
    but the provider API rejected the change.
    """

    ok: bool
    message: Optional[str] = None  # human-readable detail when ok=False


@dataclass(frozen=True)
class SIPTransportDetails:
    """Connection details for one supported inbound SIP transport."""

    transport: str
    hostname: str
    port: int
    uri: str


@dataclass(frozen=True)
class SIPRegionDetails:
    """Inbound and outbound SIP details for one provider region."""

    region: str
    inbound_transports: list[SIPTransportDetails]
    outbound_origin_ip: str


@dataclass(frozen=True)
class SIPConnectivityDetails:
    """Provider-supplied SIP connection details displayed to customers."""

    provider_display_name: str
    regions: list[SIPRegionDetails]


class ProviderPhoneNumberLookupError(Exception):
    """The provider could not determine whether it owns a phone number.

    This is distinct from a successful lookup that reports ``ok=False``. The
    latter means the address is definitely absent from the provider account;
    this exception means credentials, transport, or the upstream API failed,
    so callers should surface a provider error instead of treating the number
    as unowned.

    ``status_code`` carries the provider's HTTP status when the lookup reached
    the API. Failure classification reads it structurally — an auth rejection
    has to be attributable as the account holder's configuration rather than a
    Dograh fault, and that must not depend on parsing the provider's wording.
    It is ``None`` when the call failed before a response (transport, DNS, or
    credentials missing locally).
    """

    def __init__(self, message: str, *, status_code: int | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code


@dataclass
class NormalizedInboundData:
    """Standardized inbound call data across all providers."""

    provider: str  # Provider name (twilio, vobiz, etc.)
    call_id: str  # Provider's call identifier
    from_number: str  # Caller phone number (E.164 format)
    to_number: str  # Called phone number (E.164 format)
    direction: str  # Call direction (should be "inbound")
    call_status: str  # Call status (ringing, answered, etc.)
    account_id: Optional[str] = None  # Provider account ID
    from_country: Optional[str] = None  # Country code of caller
    to_country: Optional[str] = None  # Country code of called number
    raw_data: Dict[str, Any] = field(default_factory=dict)  # Original webhook data


class TelephonyProvider(ABC):
    """
    Abstract base class for telephony providers.
    All telephony providers must implement these core methods.
    """

    PROVIDER_NAME = None
    WEBHOOK_ENDPOINT = None

    # Populated by provider constructors from the factory-normalized config.
    from_numbers: List[str] = []
    default_from_number: Optional[str] = None
    # Carrier paths on this configuration, and the trunk each active number is
    # authorized on. Empty unless the provider's integration models trunks.
    trunks: List[Dict[str, Any]] = []
    trunk_id_by_number: Dict[str, Optional[int]] = {}

    def select_trunk(self, from_number: Optional[str]) -> Optional[Dict[str, Any]]:
        """The carrier path a call presenting ``from_number`` must take.

        The number's own trunk wins, because a carrier rejects — or declines to
        attest — a caller ID it does not own. A number with no trunk falls back
        to the sole enabled one, which is unambiguous; with several there is no
        honest answer, so the call goes out unpinned rather than down a carrier
        picked at random. The setup checklist asks the operator to assign the
        number rather than leaving that to chance.

        Returns None when the resolved trunk is disabled: the operator switched
        it off, so routing the call over a different one would present exactly
        the mismatched caller ID this method exists to avoid.
        """
        enabled = [trunk for trunk in self.trunks if trunk.get("enabled")]

        trunk_id = self.trunk_id_by_number.get(from_number) if from_number else None
        if trunk_id is not None:
            return next(
                (trunk for trunk in enabled if trunk.get("id") == trunk_id), None
            )

        return enabled[0] if len(enabled) == 1 else None

    def select_from_number(self, from_number: Optional[str] = None) -> Optional[str]:
        """Resolve the caller ID for a one-off outbound call.

        Preference order: explicit ``from_number`` > the configuration's
        default caller ID > random pick from the pool. Callers that want
        rotation across the pool (e.g. the campaign dispatcher) must pass an
        explicit ``from_number`` — the default caller ID only applies when no
        number was requested. Returns None when the pool is empty and no
        default is set.
        """
        if from_number:
            return from_number
        if self.default_from_number:
            return self.default_from_number
        if self.from_numbers:
            return random.choice(self.from_numbers)
        return None

    @abstractmethod
    async def initiate_call(
        self,
        to_number: str,
        webhook_url: str,
        workflow_run_id: Optional[int] = None,
        from_number: Optional[str] = None,
        **kwargs: Any,
    ) -> CallInitiationResult:
        """
        Initiate an outbound call.

        Args:
            to_number: The destination phone number
            webhook_url: The URL to receive call events
            workflow_run_id: Optional workflow run ID for tracking
            from_number: Optional caller ID to use. If None, the config's
                default caller ID is used when set, else one is selected
                randomly from the pool.
            **kwargs: Provider-specific additional parameters

        Returns:
            CallInitiationResult with standardized call details
        """
        pass

    @abstractmethod
    async def get_call_status(self, call_id: str) -> Dict[str, Any]:
        """
        Get the current status of a call.

        Args:
            call_id: The provider-specific call identifier

        Returns:
            Dict containing call status information
        """
        pass

    @abstractmethod
    async def get_available_phone_numbers(self) -> List[str]:
        """
        Get list of available phone numbers for this provider.

        Returns:
            List of phone numbers that can be used for outbound calls
        """
        pass

    @abstractmethod
    def validate_config(self) -> bool:
        """
        Validate that the provider is properly configured.

        Returns:
            True if configuration is valid, False otherwise
        """
        pass

    @abstractmethod
    async def verify_webhook_signature(
        self, url: str, params: Dict[str, Any], signature: str
    ) -> bool:
        """
        Verify webhook signature for security.

        Args:
            url: The webhook URL
            params: The webhook parameters
            signature: The signature to verify

        Returns:
            True if signature is valid, False otherwise
        """
        pass

    @abstractmethod
    async def get_webhook_response(
        self, workflow_id: int, organization_id: int, workflow_run_id: int
    ) -> str:
        """
        Generate the initial webhook response for starting a call session.

        Args:
            workflow_id: The workflow ID
            organization_id: The organization owning the workflow; providers
                embed it in the media websocket URL they hand back
            workflow_run_id: The workflow run ID

        Returns:
            Provider-specific response (e.g., TwiML for Twilio)
        """
        pass

    @abstractmethod
    async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
        """
        Get cost information for a completed call.

        Args:
            call_id: Provider-specific call identifier (SID for Twilio, UUID for Vonage)

        Returns:
            Dict containing:
                - cost_usd: The cost in USD as float
                - duration: Call duration in seconds
                - status: Call completion status
                - raw_response: Full provider response for debugging
        """
        pass

    @abstractmethod
    def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Parse provider-specific status callback data into generic format.

        Args:
            data: Raw callback data from the provider

        Returns:
            Dict with standardized fields:
                - call_id: Provider's call identifier
                - status: Standardized status (completed, failed, busy, etc.)
                - from_number: Optional caller number
                - to_number: Optional recipient number
                - duration: Optional call duration
                - extra: Provider-specific additional data
        """
        pass

    def get_sip_connectivity_details(self) -> SIPConnectivityDetails | None:
        """Return inbound SIP trunk details when this provider supports them.

        Providers opt in by overriding this method. Keeping the default empty
        lets the configuration UI render the SIP panel generically without a
        hard-coded list of capable providers.
        """
        return None

    @abstractmethod
    async def handle_websocket(
        self,
        websocket: "WebSocket",
        workflow_id: int,
        organization_id: int,
        workflow_run_id: int,
    ) -> None:
        """
        Handle provider-specific WebSocket connection for real-time call audio.

        This method encapsulates all provider-specific WebSocket handshake and
        message routing logic, keeping the main websocket endpoint clean.

        ``organization_id`` is the tenant that every workflow/run lookup must be
        scoped by. The workflow owner is deliberately not passed in — derive it
        from the workflow row where it's needed for attribution.

        Args:
            websocket: The WebSocket connection
            workflow_id: The workflow ID
            organization_id: The organization owning the workflow and run
            workflow_run_id: The workflow run ID
        """
        pass

    # ======== INBOUND CALL METHODS ========

    @classmethod
    @abstractmethod
    def can_handle_webhook(
        cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
    ) -> bool:
        """
        Determine if this provider can handle the incoming webhook.

        Args:
            webhook_data: The parsed webhook payload
            headers: HTTP headers from the webhook request

        Returns:
            True if this provider should handle this webhook, False otherwise
        """
        pass

    @staticmethod
    @abstractmethod
    def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
        """
        Parse provider-specific inbound webhook data into normalized format.

        Args:
            webhook_data: Raw webhook data from the provider

        Returns:
            NormalizedInboundData with standardized fields
        """
        pass

    @staticmethod
    @abstractmethod
    def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
        """
        Validate that the account_id from webhook matches the provider configuration.

        Args:
            config_data: Provider configuration data from organization
            webhook_account_id: Account ID from the webhook

        Returns:
            True if account_id matches, False otherwise
        """
        pass

    @abstractmethod
    async def verify_inbound_signature(
        self,
        url: str,
        webhook_data: Dict[str, Any],
        headers: Dict[str, str],
        body: str = "",
    ) -> bool:
        """
        Verify the signature of an inbound webhook for security.

        Each provider extracts its own signature/timestamp/nonce headers.
        Returning True when no signature is present means "no verification
        attempted" — providers should return False if a signature *is*
        present but invalid.

        Args:
            url: The full webhook URL the provider POSTed to
            webhook_data: Parsed webhook payload (form fields or JSON)
            headers: HTTP headers from the request (case-insensitive lookup
                is the provider's responsibility)
            body: Raw request body — only used by providers that sign over
                the body bytes (e.g. Vobiz)

        Returns:
            True if signature is valid (or none required), False otherwise
        """
        pass

    @abstractmethod
    async def start_inbound_stream(
        self,
        *,
        websocket_url: str,
        workflow_run_id: int,
        normalized_data: "NormalizedInboundData",
        backend_endpoint: str,
    ) -> Any:
        """
        Bring up the inbound media stream for this provider and return the
        HTTP response body the webhook caller expects.

        Markup-response providers (Twilio, Plivo, Vobiz, ...) build and
        return their TwiML/XML/NCCO directly. Call-control providers
        (Telnyx) issue the REST calls needed to answer the call and start
        streaming, then return a simple acknowledgement.

        Args:
            websocket_url: WebSocket URL for audio streaming
            workflow_run_id: Workflow run ID for tracking
            normalized_data: Parsed inbound webhook payload (provides
                ``call_id`` for providers that need it)
            backend_endpoint: Public HTTPS base URL of this backend
                (already resolved by the caller); providers that need to
                build status / events URLs use this instead of re-fetching

        Returns:
            FastAPI Response object (or dict/JSON-serializable value)
        """
        pass

    async def handle_external_websocket(
        self,
        websocket: "WebSocket",
        *,
        organization_id: int,
        workflow_id: int,
        workflow_run_id: int,
        params: Dict[str, str],
    ) -> None:
        """Handle the provider-specific agent-stream WebSocket.

        Used by ``/api/v1/agent-stream/{provider_name}/{workflow_uuid}`` when
        the caller carries a provider stream protocol. ``organization_id`` is
        passed so providers can scope any config lookups to the workflow's org.
        Default raises so providers that haven't opted in fail loudly.

        The route holds an org concurrency slot while this runs, so
        implementations must bound their pre-pipeline handshake reads with a
        timeout — an idle socket must not hold the slot indefinitely.
        """
        raise NotImplementedError(
            f"Agent-stream not supported for provider {self.PROVIDER_NAME}"
        )

    async def configure_inbound(
        self, address: str, webhook_url: Optional[str]
    ) -> ProviderSyncResult:
        """Sync inbound routing for ``address`` to the provider.

        ``webhook_url`` set: point the provider's resource for this number at
        the URL. ``None``: clear it. Default is a no-op for providers that
        don't support programmatic webhook configuration (e.g. ARI).
        """
        return ProviderSyncResult(ok=True)

    async def provision_phone_number(self, address: str) -> ProviderSyncResult | None:
        """Provision ``address`` at the provider before storing it locally.

        The default ``None`` means the provider does not support provisioning,
        so the shared route falls back to the read-only ownership validation
        below. Providers that opt in must make this operation idempotent.
        """
        return None

    @abstractmethod
    async def validate_phone_number(self, address: str) -> ProviderSyncResult:
        """Check that ``address`` belongs to this provider configuration.

        Carrier-backed providers implement a read-only account-inventory
        lookup. PBX-managed providers without a carrier ownership resource
        must explicitly opt out so a newly registered provider cannot silently
        bypass validation.
        """
        raise NotImplementedError

    @staticmethod
    @abstractmethod
    def generate_error_response(error_type: str, message: str) -> tuple:
        """
        Generate a provider-specific error response.

        Args:
            error_type: Type of error (auth_failed, not_configured, etc.)
            message: Error message

        Returns:
            Tuple of (Response, media_type) - Response object and content type
        """
        pass

    # ======== CALL TRANSFER METHODS ========

    @abstractmethod
    async def transfer_call(
        self,
        destination: str,
        transfer_id: str,
        conference_name: str,
        timeout: int = 30,
        **kwargs: Any,
    ) -> Dict[str, Any]:
        """
        Initiate a call transfer to a destination number.

        Args:
            destination: The destination phone number (E.164 format)
            transfer_id: Unique identifier for tracking this transfer
            conference_name: Name of the conference to join the destination into
            timeout: Transfer timeout in seconds
            **kwargs: Provider-specific additional parameters

        Returns:
            Dict containing:
                - call_sid: Provider's call identifier
                - status: Transfer initiation status
                - provider: Provider name

        Raises:
            NotImplementedError: If provider doesn't support transfers
            ValueError: If provider configuration is invalid
        """
        pass

    @abstractmethod
    def supports_transfers(self) -> bool:
        """
        Check if this provider supports call transfers.

        Returns:
            True if provider supports call transfers, False otherwise
        """
        pass

    async def transfer_external_pbx_call(
        self,
        *,
        identity: Dict[str, Any],
        destination: str,
        field_updates: Optional[Dict[str, str]] = None,
        disposition: Optional[str] = None,
    ) -> Optional[Dict[str, Any]]:
        """Handle an external-PBX-owned customer leg when one is present.

        ``disposition`` is the outcome to record on the PBX's own copy of the
        call, already translated through the organization's disposition
        mapping. The caller resolves it because the transfer completes before
        the disposition is stamped on the run.

        Providers without an external PBX return ``None`` so the ordinary
        telephony transfer path continues unchanged.
        """

        return None
