from hashlib import sha256
import re
import logging

from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import connection, transaction
from django.utils import timezone

from apps.access.authorization import require_permission
from apps.customers.models import Customer, CustomerContact, normalize_mobile
from apps.organization.models import Company
from apps.service.locking import share
from .models import NotificationTemplate, Notification, NotificationAttempt
from .providers import get_provider, DeliveryResult
from .rendering import render

logger = logging.getLogger("apps.communications.delivery")


def log_attempt(attempt):
    # Never include provider response text, references, destinations or content.
    # A logging outage cannot invalidate an already recorded transport outcome.
    try:
        logger.info("notification_attempt", extra={"attempt_id": str(attempt.pk),
            "delivery_provider": attempt.provider, "delivery_outcome": attempt.result,
            "failure_category": attempt.error_code})
    except Exception:
        pass


def authorize(actor, row, permission="communications.send_notification"):
    require_permission(user=actor, permission=permission, target=row.service_center or row.company)


def save_template(*, actor, company, code, name, channel, subject, body, is_active=True, template=None):
    with transaction.atomic():
        company = Company.objects.select_for_update().get(pk=company.pk)
        require_permission(user=actor, permission="communications.manage_templates", target=company)
        row = NotificationTemplate.objects.select_for_update().get(pk=template.pk, company=company) if template else NotificationTemplate(company=company)
        if template and (row.code != code or row.channel != channel):
            raise ValidationError("Template code/channel are immutable; create another template.")
        for key, value in dict(code=code, name=name, channel=channel, subject=subject, body=body, is_active=is_active).items():
            setattr(row, key, value)
        row.updated_by, row.updated_at = actor, timezone.now()
        row._persist()
        return row


def destination(customer, channel):
    """Existing active primary contact first, then the existing customer primary field."""
    if not customer.is_active or not customer.company.is_active:
        raise ValidationError("Active customer and company required.")
    kind = {"SMS": "MOBILE", "EMAIL": "EMAIL"}.get(channel)
    if kind is None:
        raise ValidationError("Unsupported channel.")
    contact = CustomerContact.objects.filter(customer=customer, contact_type=kind, is_active=True, is_primary=True).first()
    value = contact.normalized_value if contact else (customer.primary_mobile if channel == "SMS" else customer.primary_email)
    if channel == "SMS":
        value = normalize_mobile(value)
    else:
        validate_email(value)
    if not value:
        raise ValidationError("No valid destination is recorded for this channel.")
    return value


def request_notification(*, actor, template, customer, service_center=None, event="MANUAL", related_id=None,
                         related_reference="", event_key, context=None):
    """Queue a snapshot only. Hooks derive context; callers cannot supply destinations."""
    if not isinstance(event_key, str) or not event_key or len(event_key) > 300:
        raise ValidationError("A bounded, stable event key is required.")
    with transaction.atomic():
        # Customer/contact writers also lock Company, serializing contact snapshots.
        company = Company.objects.select_for_update().get(pk=customer.company_id)
        row_customer = Customer.objects.get(pk=customer.pk, company=company)
        if service_center:
            service_center = type(service_center).objects.get(pk=service_center.pk)
            if service_center.company_id != company.pk or not service_center.is_active:
                raise ValidationError("Center must be active and belong to the customer company.")
        require_permission(user=actor, permission="communications.send_notification", target=service_center or company)
        current = NotificationTemplate.objects.filter(pk=template.pk, company=company).first()
        if current is None:
            raise ValidationError("Template must belong to the customer company.")
        key = sha256(f"{current.channel}:{event_key}".encode()).hexdigest()
        existing = Notification.objects.filter(company=company, idempotency_key=key).first()
        if existing:
            if (existing.customer_id, existing.service_center_id, existing.event, existing.related_id) != (
                    row_customer.pk, service_center.pk if service_center else None, event, related_id):
                raise ValidationError("Idempotency key already belongs to another request.")
            return existing
        if not current.is_active:
            raise ValidationError("Template is inactive.")
        address = destination(row_customer, current.channel)
        values = dict(context or {}) | {"customer_name": row_customer.display_name}
        subject, body = render(current, values)
        row = Notification(company=company, service_center=service_center, customer=row_customer,
            template=current, template_code=current.code, channel=current.channel, destination=address,
            subject=subject, body=body, event=event, related_id=related_id, related_reference=related_reference,
            idempotency_key=key, requested_by=actor)
        row._persist()
        return row


def send_notification(*, actor, notification):
    """Commit a claim BEFORE I/O. Unknown/crashed claims are never automatically resent."""
    if connection.in_atomic_block:
        raise ValidationError("Dispatch after the authoritative transaction commits.")
    with transaction.atomic():
        row = Notification.objects.select_for_update().get(pk=notification.pk)
        authorize(actor, row)
        if row.status not in ("PENDING", "FAILED"):
            return row
        # Recheck the authoritative destination, but never silently replace the snapshot.
        try:
            customer = Customer.objects.get(pk=row.customer_id)
            contact_valid = destination(customer, row.channel) == row.destination
        except ValidationError:
            contact_valid = False
        from .hooks import event_is_current
        context_valid = event_is_current(row)
        try:
            provider = get_provider(row.channel)
            provider_name = provider.identifier
            if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", provider_name):
                raise ValueError
        except Exception:
            provider, provider_name = None, "configuration-error"
        attempt = NotificationAttempt(notification=row, number=row.attempts.count() + 1,
                                      provider=provider_name, actor=actor)
        attempt._persist()
        row.status = "SENDING"
        row._persist()
    # A process crash from here leaves SENDING/STARTED, preventing duplicate sends.
    if not contact_valid or not context_valid or provider is None:
        result = DeliveryResult("REJECTED")
    else:
        try:
            result = provider.send(destination=row.destination, subject=row.subject, body=row.body,
                                   idempotency_key=str(row.pk))
            if not isinstance(result, DeliveryResult) or result.outcome not in ("ACCEPTED", "REJECTED", "UNKNOWN"):
                result = DeliveryResult("UNKNOWN")
        except Exception:
            result = DeliveryResult("UNKNOWN")
    with transaction.atomic():
        row = Notification.objects.select_for_update().get(pk=row.pk)
        attempt = NotificationAttempt.objects.select_for_update().get(pk=attempt.pk)
        attempt.result, attempt.finished_at = result.outcome, timezone.now()
        # References can contain arbitrary provider text. Preserve only a bounded opaque ID.
        ref = result.reference if isinstance(result.reference, str) else ""
        attempt.provider_reference = ref if result.outcome == "ACCEPTED" and re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", ref) else ""
        attempt.error_code = ("CONTACT_CHANGED" if not contact_valid else "CONTEXT_CHANGED" if not context_valid else "PROVIDER_CONFIGURATION" if provider is None
            else {"ACCEPTED": "", "REJECTED": "NOT_ACCEPTED", "UNKNOWN": "OUTCOME_UNKNOWN"}[result.outcome])
        attempt._persist()
        row.status = {"ACCEPTED": "SENT", "REJECTED": "FAILED", "UNKNOWN": "UNKNOWN"}[result.outcome]
        row.sent_at = attempt.finished_at if row.status == "SENT" else None
        row._persist()
        transaction.on_commit(lambda: log_attempt(attempt))
        return row


def cancel_notification(*, actor, notification):
    with transaction.atomic():
        row = Notification.objects.select_for_update().get(pk=notification.pk)
        authorize(actor, row)
        if row.status == "CANCELLED":
            return row
        if row.status not in ("PENDING", "FAILED"):
            raise ValidationError("Only pending or definitively failed notifications can be cancelled.")
        row.status, row.cancelled_at, row.cancelled_by = "CANCELLED", timezone.now(), actor
        row._persist()
        return row
