"""
Crear avisos y marcarlos leídos.

Todo lo que genera un aviso pasa por `notify()`, y `notify()` no escribe dos
veces el mismo hecho. Esa es la única regla que sostiene que la campana sirva:
un número que crece solo, con avisos repetidos de lo mismo, se ignora en tres
días y a partir de ahí el producto pierde su único canal para decir que algo se
rompió.

Los avisos no prometen indexación ni la mencionan como resultado. «El lote
terminó» es un hecho verificable; «tus URLs se indexaron» no es algo que este
producto pueda afirmar (R-D).
"""

from django.db import IntegrityError, transaction
from django.urls import reverse
from django.utils import timezone

from apps.core.tables import slug
from apps.notifications.models import Notification, NotificationKind, NotificationLevel
from apps.sitemaps.models import CoverageState

#: Cuántos avisos conserva una cuenta.
#:
#: Los ya leídos se borran al crear uno nuevo. Sin tope, una cuenta con seis
#: dominios acumula dos mil avisos por año y la pantalla pagina un historial que
#: nadie consulta; lo que se mira es lo de esta semana.
MAX_PER_ACCOUNT = 200


def notify(
    account,
    *,
    kind: str,
    text_key: str,
    dedupe_key: str,
    params: dict | None = None,
    domain=None,
    level: str = NotificationLevel.INFO,
    action_path: str = '',
) -> Notification | None:
    """
    Deja un aviso, salvo que ese hecho ya esté avisado.

    Lo que se guarda son la clave de la frase y sus datos, nunca la frase: el
    texto lo arma el catálogo del idioma de quien lo lea, que puede no ser el
    mismo que tenía la cuenta el día que el aviso se creó.

    Devuelve nada cuando el aviso ya existía. Es información útil para quien
    llama —significa «esto ya estaba dicho»— y evita tener que consultar antes
    de escribir, que es donde se cuela la condición de carrera: dos trabajadores
    procesando el mismo lote pasarían los dos por el «no existe» y escribirían
    los dos.

    La colisión la atrapa la base. El `atomic` anidado existe porque un
    `IntegrityError` deja la transacción de la petición inutilizable en
    PostgreSQL: sin él, un aviso duplicado haría fallar todo lo que viniera
    después, incluido el trabajo que sí había que guardar.
    """
    try:
        with transaction.atomic():
            notification = Notification.objects.create(
                account=account,
                domain=domain,
                kind=kind,
                level=level,
                text_key=text_key,
                params=params or {},
                action_path=action_path,
                dedupe_key=dedupe_key,
            )
    except IntegrityError:
        return None

    _prune(account)
    return notification


def unread_count(account) -> int:
    return Notification.objects.filter(account=account, read_at=None).count()


def company_notifications():
    """
    Avisos del scope compartido de la instalación.

    Los avisos siguen guardando `account` para conservar el modelo multitenant,
    pero en este MVP todos los perfiles leen la misma bandeja de la compañía.
    """
    return Notification.objects.all()


def company_unread_count() -> int:
    return company_notifications().filter(read_at=None).count()


def company_unread_action_count() -> int:
    return company_notifications().filter(
        read_at=None, level=NotificationLevel.ACTION
    ).count()


def mark_company_read(notification_id) -> bool:
    """Marca un aviso de la bandeja compartida como leído para la compañía."""
    return bool(
        company_notifications().filter(id=notification_id, read_at=None).update(
            read_at=timezone.now()
        )
    )


def mark_all_company_read() -> int:
    """Limpia el contador común; en este MVP el estado de lectura es compartido."""
    return company_notifications().filter(read_at=None).update(read_at=timezone.now())


def unread_action_count(account) -> int:
    """
    Cuántos de los sin leer piden algo de la persona.

    Va aparte del total porque son dos preguntas distintas: «cuánto me falta
    mirar» y «cuánto tengo que resolver». Seis de los ocho tipos son de acción
    pero por volumen manda el informativo —hay un aviso por lote, por dominio y
    por día—, así que un «12 sin leer» no dice si adentro hay algo roto.

    Se cuenta sobre la cuenta entera y no sobre la página. Derivarlo de las 50
    filas que se muestran daría una cifra falsa en cuanto haya una segunda
    página, y sería falsa justo hacia abajo: diría que hay menos por resolver de
    lo que hay.
    """
    return Notification.objects.filter(
        account=account, read_at=None, level=NotificationLevel.ACTION
    ).count()


def mark_read(account, notification_id) -> bool:
    """Marca uno como leído. Devuelve si había algo que marcar."""
    return bool(
        Notification.objects.filter(account=account, id=notification_id, read_at=None).update(
            read_at=timezone.now()
        )
    )


def mark_all_read(account) -> int:
    """
    Marca todos como leídos y devuelve cuántos eran.

    No borra: leído y borrado son cosas distintas. Alguien que marca todo para
    limpiar el número sigue pudiendo volver a leer qué decían.
    """
    return Notification.objects.filter(account=account, read_at=None).update(read_at=timezone.now())


def _prune(account) -> None:
    """Deja sólo los últimos, sin tocar los que todavía no se leyeron."""
    stale = (
        Notification.objects.filter(account=account)
        .exclude(read_at=None)
        .order_by('-created_at')
        .values_list('id', flat=True)[MAX_PER_ACCOUNT:]
    )
    Notification.objects.filter(id__in=list(stale)).delete()


# --- Los avisos que el sistema sabe dar -------------------------------------


def batch_finished(batch) -> Notification | None:
    """
    Un lote terminó, con lo que hizo y lo que quedó pendiente.

    Un lote `PARTIAL` no es un problema y su aviso no lo trata como tal: lo que
    quedó entra en el ciclo siguiente sin que nadie haga nada (R-C). El que sí
    pide una acción es el que falló.
    """
    from apps.jobs.models import BatchState

    domain = batch.domain

    # El interruptor del dominio decide si su trabajo genera avisos. Es lo que
    # esa perilla dice que hace, y hasta ahora no la leía nadie.
    if not domain.notifications_enabled:
        return None

    if batch.state == BatchState.FAILED:
        return notify(
            domain.account,
            domain=domain,
            kind=NotificationKind.BATCH_FAILED,
            level=NotificationLevel.ACTION,
            text_key='BATCH_FAILED',
            params={'hostname': domain.hostname},
            dedupe_key=f'batch:{batch.id}',
            action_path=reverse('batch.show', kwargs={'batch_id': batch.id}),
        )

    if batch.state == BatchState.PARTIAL:
        pending = max(batch.total_items - batch.processed_items - batch.failed_items, 0)
        return notify(
            domain.account,
            domain=domain,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_PARTIAL',
            params={
                'hostname': domain.hostname,
                'processed': batch.processed_items,
                'total': batch.total_items,
                'pending': pending,
            },
            dedupe_key=f'batch:{batch.id}',
            action_path=reverse('batch.show', kwargs={'batch_id': batch.id}),
        )

    if batch.state == BatchState.COMPLETED and batch.processed_items:
        return notify(
            domain.account,
            domain=domain,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            params={
                'hostname': domain.hostname,
                'processed': batch.processed_items,
                'total': batch.total_items,
            },
            dedupe_key=f'batch:{batch.id}',
            action_path=reverse('batch.show', kwargs={'batch_id': batch.id}),
        )

    return None


def access_lost(domain) -> Notification | None:
    """
    Dejamos de poder leer una propiedad.

    La llave lleva la fecha del día: si el acceso se recupera y se vuelve a
    perder un mes después, eso es un hecho nuevo y merece su aviso. Sin la
    fecha, el segundo se descartaría por duplicado y nadie se enteraría.
    """
    from apps.core.dates import today

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.ACCESS_LOST,
        level=NotificationLevel.ACTION,
        text_key='ACCESS_LOST',
        params={'hostname': domain.hostname},
        dedupe_key=f'access-lost:{domain.id}:{today().isoformat()}',
        action_path=reverse('domain.show', kwargs={'domain_id': domain.id}),
    )


def credential_invalid(account, credential) -> Notification | None:
    """
    La credencial de la cuenta dejó de servir, que apaga todo lo demás.

    Es el aviso de nivel cuenta: sin credencial no se puede consultar ninguna
    propiedad, así que no lleva dominio y va con nivel de acción.
    """
    return notify(
        account,
        kind=NotificationKind.CREDENTIAL_INVALID,
        level=NotificationLevel.ACTION,
        text_key='CREDENTIAL_INVALID',
        dedupe_key=f'credential:{credential.id}:{credential.last_error_code}',
        action_path=reverse('settings'),
    )


def _coverage_filtered(domain, state: str) -> str:
    """
    La cobertura del dominio con un estado ya filtrado.

    El filtro viaja en la dirección porque así lo lee la tabla (RT-09): el aviso
    lleva directamente a las URLs de las que habla, en vez de dejar a la persona
    buscando doce direcciones entre cinco mil.
    """
    return f'{reverse("coverage", kwargs={"domain_id": domain.id})}?state={slug(state)}'


def fetch_errors(domain, *, count: int, batch=None) -> Notification | None:
    """
    URLs que Google no pudo descargar en el último recorrido (T116).

    Va aparte de la caída de indexación porque se arregla en otro lado: esto
    ocurre en el servidor del sitio, no en el índice de Google. El texto no
    afirma cuál es la causa —puede ser un corte, un error de la aplicación o un
    bloqueo a Google— y por eso lleva a la lista filtrada, que es donde están
    las direcciones concretas para mirar.
    """
    if not domain.notifications_enabled or count <= 0:
        return None

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.FETCH_ERRORS,
        level=NotificationLevel.ACTION,
        text_key='FETCH_ERRORS',
        params={'hostname': domain.hostname, 'count': count},
        dedupe_key=f'fetch-errors:{batch.id if batch else domain.id}',
        action_path=_coverage_filtered(domain, CoverageState.FETCH_ERROR),
    )


def crawl_blocked(domain, *, count: int, batch=None) -> Notification | None:
    """
    URLs que pasaron a estar bloqueadas por `robots.txt` (T116).

    Puede ser deliberado —hay secciones que se bloquean a propósito— así que el
    aviso informa el cambio y no lo trata como una falla. Lo que hace falta
    saber es que ocurrió: un `robots.txt` que se despliega mal saca del índice
    secciones enteras sin que nada más lo delate.
    """
    if not domain.notifications_enabled or count <= 0:
        return None

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.CRAWL_BLOCKED,
        level=NotificationLevel.ACTION,
        text_key='CRAWL_BLOCKED',
        params={'hostname': domain.hostname, 'count': count},
        dedupe_key=f'crawl-blocked:{batch.id if batch else domain.id}',
        action_path=_coverage_filtered(domain, CoverageState.BLOCKED_ROBOTS),
    )


def export_ready(export) -> Notification | None:
    """
    Un archivo de exportación quedó armado y se puede bajar.

    Es el único aviso que no depende del interruptor del dominio: los otros
    informan trabajo que la plataforma hizo sola, y éste contesta algo que la
    persona pidió a mano hace un rato. Apagar los avisos de un dominio no puede
    significar que su exportación quede lista sin que nadie se entere.

    El destino es la ficha del lote y no el archivo: un aviso que dispara una
    descarga al tocarlo sorprende, y en la ficha están el tamaño, el recorte y
    hasta cuándo se puede bajar.
    """
    from apps.core.dates import display_timezone

    domain = export.batch.domain
    # En la zona de presentación y no en UTC: en la franja horaria en que no
    # coinciden, el aviso diría un día distinto del que muestra la pantalla al
    # lado del mismo archivo (RT-01).
    expires_on = export.expires_at.astimezone(display_timezone())

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.EXPORT_READY,
        text_key='EXPORT_READY',
        # La fecha viaja en ISO y la formatea el cliente en el idioma de quien
        # lee: `18/08/2026` y `08/18/2026` son el mismo día escrito al revés, y
        # elegir uno acá se lo impone al otro.
        params={
            'hostname': domain.hostname,
            'rows': export.row_count,
            'expires_at': expires_on.isoformat(),
        },
        dedupe_key=f'export:{export.id}',
        action_path=reverse('batch.show', kwargs={'batch_id': export.batch_id}),
    )


def indexing_batch_ready(batch) -> Notification | None:
    """
    Quedó un lote de indexación esperando confirmación.

    Existe porque nada sale a Google sin que una persona lo apruebe, y dos de
    los tres caminos que arman lotes no tienen a nadie delante: uno lo dispara
    un despliegue por la API y el otro un recorrido de fondo. Sin este aviso,
    esos borradores se acumulan sin que nadie se entere, que es exactamente el
    problema con el que empezó este producto.

    El camino a mano **no llama acá**: quien acaba de armarlo lo tiene en
    pantalla, y avisarle de algo que está mirando es ruido.

    **Es de nivel acción**: pide algo concreto de la persona —revisar y
    ejecutar— y no informa un hecho ya consumado.

    La llave cuelga del lote y no del dominio. Dos borradores distintos son dos
    pendientes distintos, y colapsarlos escondería uno de los dos.
    """
    domain = batch.domain

    if not domain.notifications_enabled:
        return None

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.INDEXING_BATCH_READY,
        level=NotificationLevel.ACTION,
        text_key='INDEXING_BATCH_READY',
        params={'hostname': domain.hostname, 'total': batch.total_items},
        dedupe_key=f'indexing-batch:{batch.id}',
        action_path=reverse('indexing.batch', kwargs={'batch_id': batch.id}),
    )


def coverage_drop(domain, *, count: int, batch=None) -> Notification | None:
    """
    URLs que estaban indexadas y dejaron de estarlo.

    Es el único aviso que habla de indexación, y lo hace informando lo que
    Google contestó, no prediciendo nada. La llave cuelga del lote porque el
    hecho es «este recorrido encontró tantas»: dos recorridos distintos que
    encuentran caídas son dos hechos.

    **Lleva al lote y no a la tabla de cobertura.** Llevaba a la tabla, que es
    donde el estado ya fue pisado por el recorrido siguiente: quien abría el
    aviso al día siguiente aterrizaba entre miles de filas indexadas, sin filtro
    ni forma de saber cuál había sido. El lote, en cambio, guarda en su resumen
    las direcciones que él vio caer, y eso no cambia nunca.
    """
    if not domain.notifications_enabled or count <= 0:
        return None

    return notify(
        domain.account,
        domain=domain,
        kind=NotificationKind.COVERAGE_DROP,
        level=NotificationLevel.ACTION,
        text_key='COVERAGE_DROP',
        params={'hostname': domain.hostname, 'count': count},
        dedupe_key=f'coverage-drop:{batch.id if batch else domain.id}',
        # Sin lote no hay dónde mirar la lista y la tabla es lo único que queda.
        # No debería ocurrir por el camino normal —la caída siempre la encuentra
        # un recorrido—, pero la firma lo admite y un aviso sin destino sería
        # peor que uno con un destino impreciso.
        action_path=(
            reverse('batch.show', kwargs={'batch_id': batch.id})
            if batch is not None
            else reverse('coverage', kwargs={'domain_id': domain.id})
        ),
    )
