"""Subscription drift reconciliation and privacy-bounded provider retention."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
from typing import Mapping, Protocol

from sqlalchemy import select
from sqlalchemy.orm import Session

from licensing_shared.canonical_json import canonicalize_json
from licensing_shared.catalog import LicensingCatalog
from licensing_shared.constants import validate_identifier

from .billing import BillingProviderFailure
from .models import (
    AuditEvent,
    IdempotencyRecord,
    OutboxEvent,
    Subscription,
    SubscriptionItem,
    WebhookEvent,
)
from .security import new_identifier
from .subscriptions import (
    STRIPE_PROVIDER_ID,
    enqueue_stripe_subscription_event,
    normalize_stripe_subscription_event,
)


DEFAULT_PROCESSED_PROVIDER_RETENTION_DAYS = 30
DEFAULT_FAILED_PROVIDER_RETENTION_DAYS = 365
MAXIMUM_RETENTION_DAYS = 3650
MAXIMUM_RECONCILIATION_SUBSCRIPTIONS = 1000


def _utc(value: datetime) -> datetime:
    if not isinstance(value, datetime):
        raise TypeError("value must be a datetime")
    if value.tzinfo is None or value.utcoffset() is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


def _bounded_positive_integer(value: object, field_name: str, maximum: int) -> int:
    if not isinstance(field_name, str) or not field_name:
        raise ValueError("field_name must be a non-empty string")
    if isinstance(value, bool) or not isinstance(value, int):
        raise TypeError(f"{field_name} must be an integer")
    if value < 1 or value > maximum:
        raise ValueError(f"{field_name} must be between one and {maximum}")
    return value


class SubscriptionProviderReader(Protocol):
    def retrieve_subscription(self, subscription_id: str) -> Mapping[str, object]:
        ...


@dataclass(frozen=True)
class SubscriptionReconciliationResult:
    checked: int
    queued: int
    unchanged: int
    already_queued: int
    failed: int

    def __post_init__(self) -> None:
        for field_name in (
            "checked",
            "queued",
            "unchanged",
            "already_queued",
            "failed",
        ):
            value = getattr(self, field_name)
            if isinstance(value, bool) or not isinstance(value, int) or value < 0:
                raise ValueError(f"{field_name} must be a non-negative integer")

    def to_mapping(self) -> dict[str, int]:
        return {
            "checked": self.checked,
            "queued": self.queued,
            "unchanged": self.unchanged,
            "alreadyQueued": self.already_queued,
            "failed": self.failed,
        }


@dataclass(frozen=True)
class SubscriptionRetentionResult:
    processed_outbox_deleted: int
    failed_outbox_deleted: int
    webhook_records_deleted: int
    expired_idempotency_deleted: int

    def __post_init__(self) -> None:
        for field_name in (
            "processed_outbox_deleted",
            "failed_outbox_deleted",
            "webhook_records_deleted",
            "expired_idempotency_deleted",
        ):
            value = getattr(self, field_name)
            if isinstance(value, bool) or not isinstance(value, int) or value < 0:
                raise ValueError(f"{field_name} must be a non-negative integer")

    def to_mapping(self) -> dict[str, int]:
        return {
            "processedOutboxDeleted": self.processed_outbox_deleted,
            "failedOutboxDeleted": self.failed_outbox_deleted,
            "webhookRecordsDeleted": self.webhook_records_deleted,
            "expiredIdempotencyDeleted": self.expired_idempotency_deleted,
        }


def reconcile_stripe_subscriptions(
    session: Session,
    catalog: LicensingCatalog,
    provider: SubscriptionProviderReader,
    price_sku_map: Mapping[str, str],
    *,
    now: datetime,
    correlation_id: str,
    maximum_subscriptions: int = 100,
) -> SubscriptionReconciliationResult:
    if not isinstance(session, Session):
        raise TypeError("session must be a Session")
    if not isinstance(catalog, LicensingCatalog):
        raise TypeError("catalog must be a LicensingCatalog")
    if not callable(getattr(provider, "retrieve_subscription", None)):
        raise TypeError("provider must expose retrieve_subscription")
    if not isinstance(price_sku_map, Mapping):
        raise TypeError("price_sku_map must be a mapping")
    normalized_now = _utc(now)
    normalized_correlation = validate_identifier(correlation_id, "correlation_id")
    limit = _bounded_positive_integer(
        maximum_subscriptions,
        "maximum_subscriptions",
        MAXIMUM_RECONCILIATION_SUBSCRIPTIONS,
    )
    with session.begin():
        subscription_ids = tuple(
            session.scalars(
                select(Subscription.id)
                .where(Subscription.provider == STRIPE_PROVIDER_ID)
                .order_by(Subscription.id)
                .limit(limit)
            )
        )
    queued = 0
    unchanged = 0
    already_queued = 0
    failed = 0
    for subscription_id in subscription_ids:
        try:
            with session.begin():
                subscription = session.get(Subscription, subscription_id)
                if subscription is None:
                    unchanged += 1
                    continue
                provider_subscription_id = subscription.provider_subscription_id
            provider_object = provider.retrieve_subscription(provider_subscription_id)
            if not isinstance(provider_object, Mapping):
                raise TypeError("provider subscription must be a mapping")
            provider_mapping = dict(provider_object)
            digest = hashlib.sha256(canonicalize_json(provider_mapping)).hexdigest()
            event = {
                "id": f"reconcile_{digest}",
                "type": "customer.subscription.updated",
                "created": int(normalized_now.timestamp()),
                "data": {"object": provider_mapping},
            }
            normalized = normalize_stripe_subscription_event(event, price_sku_map)
            raw_payload = canonicalize_json(event)
            with session.begin():
                subscription = session.get(Subscription, subscription_id)
                if subscription is None:
                    unchanged += 1
                    continue
                if normalized["subscriptionId"] != subscription.provider_subscription_id:
                    raise ValueError("provider returned a different subscription")
                if normalized["licenseId"] != subscription.license_id:
                    raise ValueError("provider subscription license metadata does not match")
                if _projection_matches(session, subscription, normalized):
                    unchanged += 1
                    continue
                if enqueue_stripe_subscription_event(
                    session,
                    normalized,
                    raw_payload,
                    now=normalized_now,
                ):
                    queued += 1
                else:
                    already_queued += 1
        except (BillingProviderFailure, TypeError, ValueError):
            failed += 1
    result = SubscriptionReconciliationResult(
        checked=len(subscription_ids),
        queued=queued,
        unchanged=unchanged,
        already_queued=already_queued,
        failed=failed,
    )
    with session.begin():
        _audit_maintenance(
            session,
            "subscription.reconciliation_completed",
            normalized_correlation,
            result.to_mapping(),
        )
    return result


def purge_subscription_provider_records(
    session: Session,
    *,
    now: datetime,
    correlation_id: str,
    processed_retention_days: int = DEFAULT_PROCESSED_PROVIDER_RETENTION_DAYS,
    failed_retention_days: int = DEFAULT_FAILED_PROVIDER_RETENTION_DAYS,
) -> SubscriptionRetentionResult:
    if not isinstance(session, Session):
        raise TypeError("session must be a Session")
    normalized_now = _utc(now)
    normalized_correlation = validate_identifier(correlation_id, "correlation_id")
    processed_days = _bounded_positive_integer(
        processed_retention_days,
        "processed_retention_days",
        MAXIMUM_RETENTION_DAYS,
    )
    failed_days = _bounded_positive_integer(
        failed_retention_days,
        "failed_retention_days",
        MAXIMUM_RETENTION_DAYS,
    )
    processed_cutoff = normalized_now - timedelta(days=processed_days)
    failed_cutoff = normalized_now - timedelta(days=failed_days)
    with session.begin():
        processed = session.scalars(
            select(OutboxEvent).where(
                OutboxEvent.status == "processed",
                OutboxEvent.processed_at.is_not(None),
                OutboxEvent.processed_at < processed_cutoff,
            )
        ).all()
        failed = session.scalars(
            select(OutboxEvent).where(
                OutboxEvent.status == "failed",
                OutboxEvent.processed_at.is_not(None),
                OutboxEvent.processed_at < failed_cutoff,
            )
        ).all()
        provider_event_ids = {
            str(value.payload_json.get("providerEventId") or "")
            for value in (*processed, *failed)
            if isinstance(value.payload_json, Mapping)
        }
        provider_event_ids.discard("")
        webhooks = (
            session.scalars(
                select(WebhookEvent).where(
                    WebhookEvent.provider == STRIPE_PROVIDER_ID,
                    WebhookEvent.provider_event_id.in_(provider_event_ids),
                )
            ).all()
            if provider_event_ids
            else []
        )
        expired_idempotency = session.scalars(
            select(IdempotencyRecord).where(
                IdempotencyRecord.expires_at <= normalized_now
            )
        ).all()
        for value in (*processed, *failed, *webhooks, *expired_idempotency):
            session.delete(value)
        result = SubscriptionRetentionResult(
            processed_outbox_deleted=len(processed),
            failed_outbox_deleted=len(failed),
            webhook_records_deleted=len(webhooks),
            expired_idempotency_deleted=len(expired_idempotency),
        )
        _audit_maintenance(
            session,
            "subscription.provider_records_purged",
            normalized_correlation,
            {
                **result.to_mapping(),
                "processedRetentionDays": processed_days,
                "failedRetentionDays": failed_days,
            },
        )
    return result


def _projection_matches(
    session: Session,
    subscription: Subscription,
    normalized: Mapping[str, object],
) -> bool:
    if not isinstance(session, Session):
        raise TypeError("session must be a Session")
    if not isinstance(subscription, Subscription):
        raise TypeError("subscription must be a Subscription")
    if not isinstance(normalized, Mapping):
        raise TypeError("normalized must be a mapping")
    if subscription.provider_customer_id != normalized.get("customerId"):
        return False
    if subscription.status != normalized.get("status"):
        return False
    if subscription.cancel_at_period_end != normalized.get("cancelAtPeriodEnd"):
        return False
    period_start = datetime.fromisoformat(
        str(normalized.get("currentPeriodStart")).replace("Z", "+00:00")
    )
    period_end = datetime.fromisoformat(
        str(normalized.get("currentPeriodEnd")).replace("Z", "+00:00")
    )
    if _utc(subscription.current_period_start) != _utc(period_start):
        return False
    if _utc(subscription.current_period_end) != _utc(period_end):
        return False
    raw_items = normalized.get("items")
    if not isinstance(raw_items, list):
        return False
    expected_items = {
        str(value.get("skuId")): (
            int(value.get("quantity", 0)),
            str(value.get("providerItemId") or "") or None,
        )
        for value in raw_items
        if isinstance(value, Mapping)
    }
    stored_items = {
        value.sku_id: (value.quantity, value.provider_item_id)
        for value in session.scalars(
            select(SubscriptionItem).where(
                SubscriptionItem.subscription_id == subscription.id
            )
        )
    }
    return expected_items == stored_items


def _audit_maintenance(
    session: Session,
    action: str,
    correlation_id: str,
    metadata: dict[str, object],
) -> None:
    session.add(
        AuditEvent(
            id=new_identifier("audit"),
            actor_type="system",
            actor_id=None,
            action=validate_identifier(action, "audit action"),
            target_type="subscription_service",
            target_id=None,
            reason=None,
            correlation_id=validate_identifier(correlation_id, "correlation_id"),
            source_address_digest=None,
            metadata_json=metadata,
        )
    )


__all__ = [
    "DEFAULT_FAILED_PROVIDER_RETENTION_DAYS",
    "DEFAULT_PROCESSED_PROVIDER_RETENTION_DAYS",
    "SubscriptionReconciliationResult",
    "SubscriptionRetentionResult",
    "purge_subscription_provider_records",
    "reconcile_stripe_subscriptions",
]
