"""
Alta de dominios y comprobación de su acceso.

La regla que gobierna el alta: **no se puede dar de alta ni operar un dominio
mientras el módulo de Search Console no tenga una credencial comprobada**.
Permitirlo dejaría al usuario con dominios cargados que no pueden hacer nada, y
sin ninguna señal de por qué. Es mejor decirlo en el momento del intento, con un
enlace a la pantalla donde se resuelve (FR-059 del listado de tareas).
"""

import re
from urllib.parse import urlparse

from django.db import transaction
from django.utils import timezone

from apps.billing import limits
from apps.core.errors import DomainNotOperational, ValidationFailed
from apps.credentials.models import Module
from apps.credentials.services import resolve_credential
from apps.domains.models import AccessState, Domain, PropertyType

#: Un hostname válido: etiquetas separadas por puntos, sin guion al principio ni
#: al final de cada una.
HOSTNAME_PATTERN = re.compile(r'^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$')


def normalize_hostname(value: str) -> str:
    """
    Deja el hostname en su forma canónica: minúsculas, sin esquema ni barra final.

    La normalización es del producto, no cosmética: `Ejemplo.com`,
    `https://ejemplo.com/` y `ejemplo.com` son el mismo sitio, y sin unificarlos
    la misma persona termina con tres dominios que se pisan la cuota entre sí.
    """
    hostname = (value or '').strip().lower()

    if '//' in hostname:
        hostname = urlparse(hostname).netloc or hostname

    hostname = hostname.rstrip('/')
    hostname = hostname.removeprefix('www.')

    if not hostname:
        raise ValidationFailed('Escribí el dominio de tu sitio.', field='hostname')

    if not HOSTNAME_PATTERN.match(hostname):
        raise ValidationFailed(
            f'«{hostname}» no tiene la forma de un dominio. Se espera algo como «ejemplo.com».',
            field='hostname',
        )

    return hostname


def property_uri_for(hostname: str, property_type: str) -> str:
    """
    Arma el identificador con el que Search Console conoce a la propiedad.

    Son dos formas distintas y no intercambiables: `sc-domain:` cubre todos los
    subdominios y esquemas, y el prefijo de URL cubre exactamente lo que dice.
    Elegir la equivocada devuelve «propiedad no encontrada» aunque el sitio esté
    perfectamente dado de alta, que es el error más desconcertante de los dos.
    """
    if property_type == PropertyType.DOMAIN:
        return f'sc-domain:{hostname}'
    return f'https://{hostname}/'


def owned_domain(account, domain_id) -> Domain | None:
    """
    El dominio, sólo si es de quien pregunta.

    El filtro por cuenta va en la consulta y no en una comprobación posterior:
    así un olvido devuelve «no existe» en vez de los datos de otra cuenta.

    Devuelve `None` en vez de fallar porque las dos capas que lo usan contestan
    distinto —la pantalla con un 404 y la API con su error estructurado—, y la
    consulta es la misma. Estaba copiada en cuatro módulos, con `apps/sitemaps/`
    importando la copia de `apps/coverage/` para poder preguntar si un dominio es
    tuyo: una dependencia al revés sostenida por un nombre privado ajeno.
    """
    return Domain.objects.filter(id=domain_id, account=account).first()


def active_domains(account):
    """
    Los sitios que la cuenta tiene conectados ahora.

    **El único lugar donde vive el criterio de «activo».** Nueve módulos
    preguntan lo mismo —el tablero, el recorrido, las tareas de cobertura y de
    sitemaps, los lotes, el estado de cuenta— y repartir el filtro entre todos
    garantiza que alguno quede sin él: un sitio dado de baja que igual se
    revalida contra Google, o que sigue contando para el tope del plan.

    Devuelve un queryset y no una lista porque casi todos los usos siguen
    filtrando o contando encima.
    """
    return Domain.objects.filter(account=account, deactivated_at__isnull=True)


def shared_domains():
    """
    Los sitios activos de la instalación de empresa única.

    Este selector es deliberadamente distinto de `active_domains(account)`.
    Hoy todos los perfiles pertenecen a la misma compañía y comparten el mismo
    inventario; el selector con cuenta queda aislado como frontera lista para
    volver a usarse cuando el producto incorpore workspaces multitenant.
    """
    return Domain.objects.filter(deactivated_at__isnull=True)


def shared_domain(domain_id) -> Domain | None:
    """Un sitio activo visible para cualquier perfil de esta instalación."""
    return shared_domains().filter(id=domain_id).first()


def company_domain(domain_id) -> Domain | None:
    """Un sitio de la compañía, incluido su historial después de darlo de baja."""
    return Domain.objects.filter(id=domain_id).first()


def primary_shared_domain() -> Domain | None:
    """El sitio principal de la compañía mientras la interfaz admita uno solo."""
    return shared_domains().order_by('created_at').first()


def ensure_company_can_connect_another() -> None:
    """Aplica el límite de un sitio a la compañía completa, no al perfil que actúa."""
    connected = primary_shared_domain()
    if connected is None:
        return

    raise ValidationFailed(
        f'This company is already connected to «{connected.hostname}». '
        f'Disconnect it before connecting a different site.',
        field='hostname',
        reason='SITE_ALREADY_CONNECTED',
        params={'hostname': connected.hostname},
    )


def ensure_can_connect_another(account) -> None:
    """
    Frena la conexión de un segundo sitio. **Es una regla de la interfaz.**

    Vive acá y no en `create_domain()` a propósito: el backend sigue admitiendo
    varios dominios por cuenta —la tabla, las rutas y las tareas no cambiaron— y
    quien integra por la API v1 puede seguir dando de alta los que quiera. Lo que
    admite uno solo es lo que se ve, así que la guarda la llaman las dos puertas
    de la interfaz —la conexión y el recorrido— y ninguna otra.

    Escrito en `create_domain()` cerraría también la API, que no es lo pedido;
    escrito en cada pantalla quedaría en dos lugares que se van a desincronizar
    en el primer cambio.
    """
    connected = active_domain(account)
    if connected is None:
        return

    raise ValidationFailed(
        f'This account is already connected to «{connected.hostname}». '
        f'Disconnect it before connecting a different site.',
        field='hostname',
        reason='SITE_ALREADY_CONNECTED',
        params={'hostname': connected.hostname},
    )


def active_owned_domain(account, domain_id) -> Domain | None:
    """
    El sitio de quien pregunta, sólo si sigue activo.

    Es la variante que usan las pantallas: una que está dada de baja no existe
    para la interfaz, así que sus cuatro vistas hijas —cobertura, sitemaps,
    lotes y la ficha— contestan 404. Sin esto alcanzaría con haber guardado una
    dirección para seguir mirando algo que la cuenta dio de baja.

    `owned_domain()` sigue existiendo sin el filtro y **no es un descuido**: la
    API tiene que poder contestar por un identificador dado de baja, porque
    quien lo guardó merece enterarse de qué pasó con él en vez de recibir un
    «no existe» que sería mentira.
    """
    return active_domains(account).filter(id=domain_id).first()


def active_domain(account) -> Domain | None:
    """
    El sitio de la cuenta, o `None`.

    La interfaz admite uno solo, pero la base sigue admitiendo varios y las
    cuentas anteriores a ese cambio pueden tener dos. Con más de uno manda el
    más antiguo: es el que tiene la serie de cobertura más larga, así que es el
    que menos se pierde de vista al dejar de nombrar a los demás.
    """
    return active_domains(account).order_by('created_at').first()


def ensure_module_ready(account) -> None:
    """
    Falla si no hay credencial comprobada, con un mensaje que apunta a la pantalla.

    Delega en la resolución de credencial en vez de repetir la comprobación:
    así hay un solo lugar donde se decide qué cuenta como «lista para operar».
    """
    resolve_credential(account, Module.SEARCH_CONSOLE)


@transaction.atomic
def create_domain(account, *, hostname: str, property_type: str) -> Domain:
    """
    Da de alta un dominio propiedad de una cuenta.

    Esta es la variante extensible para multitenancy: tanto la credencial como
    el dominio quedan dentro de la misma frontera de cuenta. La instalación de
    empresa única entra por `create_company_domain()`.
    """
    ensure_module_ready(account)

    if property_type not in PropertyType.values:
        raise ValidationFailed(
            'Elegí si la propiedad es de dominio o de prefijo de URL.', field='property_type'
        )

    normalized = normalize_hostname(hostname)
    property_uri = property_uri_for(normalized, property_type)

    existing = Domain.objects.filter(account=account, property_uri=property_uri).first()
    if existing is not None:
        if existing.deactivated_at is None:
            # El identificador del que ya existe viaja con el error. Sin él, «ya
            # está dado de alta» deja a la persona buscándolo a mano en el
            # listado para comprobar qué tiene configurado.
            raise ValidationFailed(
                f'«{normalized}» ya está dado de alta en tu cuenta con esa forma de propiedad.',
                field='hostname',
                duplicate_id=str(existing.id),
            )

        # Volver a dar de alta un sitio que ya fue tuyo **reactiva su fila**, y
        # no es una comodidad: `one_property_per_account` es una restricción de
        # base y crear otra reventaría con `IntegrityError`. De paso recupera su
        # cobertura, sus sitemaps y sus lotes, que nunca se borraron.
        #
        # El estado de acceso y la fecha de la última comprobación se conservan:
        # darlo de baja acá no le cambió el permiso en Search Console, y
        # reiniciarlos fabricaría un «sin comprobar» que no es cierto. Quien
        # reactiva comprueba después, y ahí se actualizan.
        existing.deactivated_at = None
        existing.save(update_fields=['deactivated_at', 'updated_at'])
        return existing

    # El tope del plan se consulta sólo acá, en la rama que crea de verdad.
    # Reactivar no consume cupo de alta: no se crea nada, y cobrarlo castigaría
    # corregir un error —diste de baja el sitio equivocado y volver te cuesta un
    # cupo—. Mover la llamada no pierde medición: `ADD_DOMAIN` no figura en
    # `METRIC_BY_ACTION`, así que no acumula ninguna métrica.
    decision = limits.check(account, limits.Action.ADD_DOMAIN)
    if not decision:
        raise ValidationFailed(decision.reason, field='hostname', limit=decision.limit)

    from django.conf import settings as django_settings

    return Domain.objects.create(
        account=account,
        hostname=normalized,
        property_type=property_type,
        property_uri=property_uri,
        daily_inspection_budget=django_settings.DEFAULT_DAILY_INSPECTION_BUDGET,
        manual_reserve=django_settings.DEFAULT_MANUAL_RESERVE,
    )


def create_company_domain(actor, *, hostname: str, property_type: str) -> Domain:
    """Crea el sitio común usando el propietario técnico estable de la compañía."""
    from apps.credentials.services import shared_resource_account

    owner = shared_resource_account(actor)
    return create_domain(owner, hostname=hostname, property_type=property_type)


@transaction.atomic
def deactivate_domain(domain: Domain) -> Domain:
    """
    Da de baja el sitio: deja de trabajarse, sin perder nada de lo que produjo.

    Estampa la fecha en vez de borrar la fila. Borrar arrastraría en cascada la
    cobertura, los sitemaps y los lotes —meses de datos— por lo que casi siempre
    es un cambio de opinión, y volver atrás sería imposible.

    Idempotente a propósito: dar de baja lo que ya está dado de baja no mueve la
    fecha. La primera es la que dice cuándo dejó de trabajarse, y pisarla con la
    del segundo clic contaría mal el tiempo que estuvo detenido.
    """
    if domain.deactivated_at is not None:
        return domain

    domain.deactivated_at = timezone.now()
    domain.save(update_fields=['deactivated_at', 'updated_at'])
    return domain


#: Lo que escribe una comprobación de acceso. Está acá y no repetido en cada
#: `save` porque olvidar uno deja la ficha mostrando el motivo de un intento
#: anterior al lado de la fecha del último: dos datos que se contradicen.
_ACCESS_FIELDS = [
    'access_state',
    'access_error',
    'access_error_code',
    'access_checked_at',
    'updated_at',
]


def check_access(domain: Domain, *, client=None) -> Domain:
    """
    Pregunta a Google si seguimos teniendo acceso a la propiedad, y lo registra.

    El paso a «acceso perdido» conserva todo el historial del dominio. Borrar sus
    datos al perder el permiso convertiría un problema reversible de permisos en
    una pérdida definitiva de la serie de cobertura.
    """
    from apps.gsc.client import SearchConsoleClient
    from apps.gsc.errors import GoogleCallError, GoogleErrorCode
    from apps.jobs.budget import reserve_verification

    reservation = reserve_verification(domain.account)
    if not reservation:
        return domain

    client = client or SearchConsoleClient(domain.account)

    try:
        client.get_site(property_uri=domain.property_uri, reservation=reservation)
    except GoogleCallError as exc:
        if exc.code in (GoogleErrorCode.PERMISSION_DENIED, GoogleErrorCode.PROPERTY_NOT_FOUND):
            new_state = (
                AccessState.ACCESS_LOST
                if domain.access_state == AccessState.OPERATIONAL
                else AccessState.AWAITING_ACCESS
            )
            domain.transition_access(new_state, error=exc.message, error_code=exc.code)
        else:
            # Un corte de Google no es una pérdida de acceso: marcarlo como tal
            # dispararía una notificación por algo que se arregla solo.
            domain.access_error = exc.message
            domain.access_error_code = exc.code
        domain.access_checked_at = timezone.now()
        domain.save(update_fields=_ACCESS_FIELDS)

        # El aviso sale sólo al caer desde operativo, que es el único caso que
        # deja el estado en `ACCESS_LOST`. Un dominio que todavía esperaba
        # autorización y sigue esperándola no cambió de situación, y avisarlo en
        # cada comprobación llenaría la campana de lo mismo.
        if domain.access_state == AccessState.ACCESS_LOST:
            from apps.notifications import services as notifications

            notifications.access_lost(domain)

        return domain

    domain.transition_access(AccessState.OPERATIONAL)
    domain.access_checked_at = timezone.now()
    domain.save(update_fields=_ACCESS_FIELDS)
    return domain


#: Lo único que se puede editar de un dominio (FR-057).
#:
#: La lista es explícita porque lo que **no** está es la mitad del contrato: la
#: forma de la propiedad y el estado de acceso describen algo que vive en Search
#: Console, y dejarlos escribir por acá permitiría declarar operativo un dominio
#: que Google nunca confirmó.
EDITABLE_SETTINGS = ('notifications_enabled', 'daily_inspection_budget', 'manual_reserve')


@transaction.atomic
def update_settings(domain: Domain, changes: dict) -> Domain:
    """
    Aplica los cambios de configuración de un dominio (FR-057).

    Recibe el cuerpo crudo y no argumentos con nombre para que la API y la
    interfaz compartan literalmente la misma validación, incluida la de «no
    mandaste nada». Con argumentos opcionales, cada superficie tendría que
    decidir por su cuenta qué significa un campo ausente, y ahí es donde las dos
    se separan.

    Las claves que no son editables se ignoran en vez de rechazarse: quien
    integra la API suele leer el dominio entero y devolverlo modificado, y
    fallar por `id` o `hostname` convertiría el camino más natural en un error.
    Ignorarlas no las hace escribibles —no se tocan— y hay una prueba que lo fija.
    """
    requested = {name: changes[name] for name in EDITABLE_SETTINGS if name in changes}

    if not requested:
        raise ValidationFailed(
            'No mandaste ningún cambio. Se puede editar el aviso por correo, el presupuesto '
            'diario de inspección y la reserva manual.',
            field='settings',
        )

    if 'notifications_enabled' in requested:
        domain.notifications_enabled = _boolean(requested['notifications_enabled'])

    # Los dos números se leen antes de validarse porque la regla los relaciona:
    # una reserva de 300 es válida o no según el presupuesto que quede después
    # de este mismo cambio, no según el que había guardado.
    budget = (
        _integer(
            requested['daily_inspection_budget'],
            'daily_inspection_budget',
            'El presupuesto diario',
        )
        if 'daily_inspection_budget' in requested
        else domain.daily_inspection_budget
    )
    reserve = (
        _integer(requested['manual_reserve'], 'manual_reserve', 'La reserva manual')
        if 'manual_reserve' in requested
        else domain.manual_reserve
    )

    if budget < 1:
        raise ValidationFailed(
            'El presupuesto diario tiene que ser de al menos 1 consulta. Con cero, el ciclo '
            'automático no consultaría nunca.',
            field='daily_inspection_budget',
        )

    if reserve < 0:
        raise ValidationFailed('La reserva manual no puede ser negativa.', field='manual_reserve')

    if reserve > budget:
        # El campo que se nombra es el que la persona tocó. Señalar siempre la
        # reserva mandaría a corregir un valor que puede estar perfecto: si lo
        # que bajó fue el presupuesto, el error está ahí.
        field_name = (
            'daily_inspection_budget'
            if 'daily_inspection_budget' in requested
            else 'manual_reserve'
        )
        raise ValidationFailed(
            f'La reserva manual ({reserve}) no puede ser mayor que el presupuesto diario '
            f'({budget}). Subí el presupuesto o bajá la reserva.',
            field=field_name,
            daily_inspection_budget=budget,
            manual_reserve=reserve,
        )

    domain.daily_inspection_budget = budget
    domain.manual_reserve = reserve
    # Los límites nuevos rigen desde el próximo ciclo y no desde ya: el
    # presupuesto de hoy copió los suyos al crearse, y reescribirlo cambiaría el
    # denominador de un consumo que ya ocurrió.
    domain.save(
        update_fields=[
            'notifications_enabled',
            'daily_inspection_budget',
            'manual_reserve',
            'updated_at',
        ]
    )
    return domain


def _boolean(value) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, str) and value.strip().lower() in ('true', 'false'):
        return value.strip().lower() == 'true'
    raise ValidationFailed(
        'El aviso por correo se enciende o se apaga: sólo admite verdadero o falso.',
        field='notifications_enabled',
    )


def _integer(value, field_name: str, label: str) -> int:
    # `bool` es subclase de `int` en Python: sin este corte, mandar `true` como
    # presupuesto guardaría un cupo diario de una consulta sin decir nada.
    if isinstance(value, bool) or not isinstance(value, int | str):
        raise ValidationFailed(f'{label} tiene que ser un número entero.', field=field_name)

    try:
        return int(str(value).strip())
    except ValueError:
        raise ValidationFailed(
            f'{label} tiene que ser un número entero, sin puntos ni comas.', field=field_name
        ) from None


def ensure_operational(domain: Domain) -> None:
    """Frena cualquier trabajo sobre un dominio cuyo acceso no está confirmado."""
    ensure_module_ready(domain.account)

    # Un sitio dado de baja se frena acá y no en cada pantalla. Es la puerta por
    # la que pasan lotes, sincronizaciones e inspecciones —incluidas las de la
    # API, que no consulta ninguna vista—, así que sin esta guarda alcanzaría con
    # conservar un identificador para seguir trabajando sobre algo que la cuenta
    # dio de baja.
    if domain.deactivated_at is not None:
        raise DomainNotOperational(
            f'«{domain.hostname}» está dado de baja. Volvé a conectarlo para trabajar con él.',
            domain_id=str(domain.id),
            access_state=domain.access_state,
        )

    if not domain.is_operational:
        raise DomainNotOperational(
            f'«{domain.hostname}» está en estado {domain.get_access_state_display().lower()}. '
            f'Hasta confirmar el acceso a su propiedad no se puede sincronizar ni inspeccionar.',
            domain_id=str(domain.id),
            access_state=domain.access_state,
        )
