"""
Recorrido completo: leer sitemaps, registrar URLs, consultar cobertura.

Es el flujo que sostiene la promesa del producto, y se ejercita entero con
Google y el sitio del usuario simulados. Lo que se verifica no es que las piezas
anden por separado —eso ya lo cubren sus tests— sino que juntas no rompan
ninguno de los invariantes: que no se gaste cuota sin reserva, que un lote
cortado no diga «terminado», y que una URL sin consultar nunca se muestre con un
estado.
"""

from pathlib import Path

import httpx
import pytest
from django.conf import settings

from apps.coverage.models import CoverageRecord
from apps.coverage.services import coverage_summary, inspect_domain
from apps.jobs.budget import budget_for, reserve
from apps.jobs.models import BatchOrigin, BatchState
from apps.sitemaps.models import CoverageState, SitemapKind, SitemapSource, SubmitResult, Url
from apps.sitemaps.services import register_sitemap, sync_domain
from tests.doubles import FakeClient, http_error, response
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db

FIXTURES = Path(settings.BASE_DIR) / 'tests' / 'fixtures' / 'sitemaps'


def fake_site(**routes) -> httpx.Client:
    """
    Simula el sitio del usuario, no Google.

    Va por separado del doble de Search Console porque son dos interlocutores
    distintos: leer un sitemap es tráfico hacia el sitio del usuario y no gasta
    cuota de Google.
    """

    def respond(request: httpx.Request) -> httpx.Response:
        body = routes.get(str(request.url))
        if body is None:
            return httpx.Response(404, text='no encontrado')
        return httpx.Response(200, content=body)

    return httpx.Client(transport=httpx.MockTransport(respond))


@pytest.fixture
def domain():
    account = create_account()
    create_credential(account)
    return create_domain(account, daily_inspection_budget=100, manual_reserve=10)


@pytest.fixture
def site():
    return fake_site(
        **{
            'https://ejemplo.test/sitemap.xml': (FIXTURES / 'indice.xml').read_bytes(),
            'https://ejemplo.test/sitemap-1.xml': (FIXTURES / 'urlset.xml').read_bytes(),
            'https://ejemplo.test/sitemap-2.xml': (
                FIXTURES / 'urlset_sin_espacio_de_nombres.xml'
            ).read_bytes(),
        }
    )


# --- Sincronización ---------------------------------------------------------


def test_an_index_discovers_its_children_and_registers_their_urls(domain, site):
    register_sitemap(domain, 'https://ejemplo.test/sitemap.xml')
    google = FakeClient(submit_sitemap={})

    batch = sync_domain(domain, origin=BatchOrigin.MANUAL, client=google, http=site)

    assert batch.state == BatchState.COMPLETED
    assert domain.sitemaps.count() == 3
    assert domain.sitemaps.get(location__endswith='sitemap.xml').kind == SitemapKind.INDEX
    assert (
        domain.sitemaps.get(location__endswith='sitemap-1.xml').source == SitemapSource.DISCOVERED
    )
    assert Url.objects.filter(domain=domain).count() == 4


def test_new_urls_start_without_data_and_not_as_not_indexed(domain, site):
    """
    El invariante que sostiene el principio I.

    Una URL recién registrada no fue consultada. Mostrarla como «sin indexar»
    sería afirmar algo que nadie comprobó.
    """
    register_sitemap(domain, 'https://ejemplo.test/sitemap.xml')
    sync_domain(domain, client=FakeClient(submit_sitemap={}), http=site)

    for url in Url.objects.filter(domain=domain):
        assert url.coverage_state == CoverageState.UNKNOWN
        assert url.last_checked_at is None


def test_a_sitemap_without_changes_is_not_resubmitted(domain, site):
    register_sitemap(domain, 'https://ejemplo.test/sitemap.xml')
    google = FakeClient(submit_sitemap={})

    first = sync_domain(domain, client=google, http=site)
    submitted_the_first_time = first.summary['sitemaps_submitted']

    second = sync_domain(domain, client=google, http=site)

    assert submitted_the_first_time == 3
    assert second.summary['sitemaps_submitted'] == 0
    assert second.summary['sitemaps_unchanged'] == 3
    assert domain.sitemaps.first().last_submit_result == SubmitResult.SKIPPED_UNCHANGED


def test_a_sitemap_that_changed_is_resubmitted(domain):
    register_sitemap(domain, 'https://ejemplo.test/sitemap-1.xml')
    google = FakeClient(submit_sitemap={})

    sync_domain(
        domain,
        client=google,
        http=fake_site(
            **{'https://ejemplo.test/sitemap-1.xml': (FIXTURES / 'urlset.xml').read_bytes()}
        ),
    )

    modified = (FIXTURES / 'urlset.xml').read_bytes().replace(b'/nota/dos', b'/nota/tres')
    second = sync_domain(
        domain,
        client=google,
        http=fake_site(**{'https://ejemplo.test/sitemap-1.xml': modified}),
    )

    assert second.summary['sitemaps_submitted'] == 1


def test_a_url_that_disappears_from_the_sitemap_is_marked_but_not_deleted(domain):
    """
    Su historial de cobertura sigue siendo cierto.

    Borrar la fila haría imposible responder después por qué una página dejó de
    aparecer, que suele ser exactamente lo que hay que averiguar.
    """
    register_sitemap(domain, 'https://ejemplo.test/sitemap-1.xml')
    google = FakeClient(submit_sitemap={})

    sync_domain(
        domain,
        client=google,
        http=fake_site(
            **{'https://ejemplo.test/sitemap-1.xml': (FIXTURES / 'urlset.xml').read_bytes()}
        ),
    )

    trimmed = (
        (FIXTURES / 'urlset.xml')
        .read_bytes()
        .replace(b'<url>\n    <loc>https://ejemplo.test/nota/dos</loc>\n  </url>', b'')
    )
    sync_domain(
        domain,
        client=google,
        http=fake_site(**{'https://ejemplo.test/sitemap-1.xml': trimmed}),
    )

    missing = Url.objects.get(domain=domain, loc='https://ejemplo.test/nota/dos')
    assert missing.in_sitemap is False
    assert Url.objects.filter(domain=domain).count() == 3


def test_a_sitemap_that_cannot_be_read_leaves_the_batch_partial_and_explains_the_reason(domain):
    register_sitemap(domain, 'https://ejemplo.test/sitemap-1.xml')
    register_sitemap(domain, 'https://ejemplo.test/no-existe.xml')

    batch = sync_domain(
        domain,
        client=FakeClient(submit_sitemap={}),
        http=fake_site(
            **{'https://ejemplo.test/sitemap-1.xml': (FIXTURES / 'urlset.xml').read_bytes()}
        ),
    )

    assert batch.state == BatchState.PARTIAL
    assert any('404' in error for error in batch.summary['errors'])


# --- Inspección -------------------------------------------------------------


def _with_urls(domain, site):
    register_sitemap(domain, 'https://ejemplo.test/sitemap-1.xml')
    sync_domain(
        domain,
        client=FakeClient(submit_sitemap={}),
        http=fake_site(
            **{'https://ejemplo.test/sitemap-1.xml': (FIXTURES / 'urlset.xml').read_bytes()}
        ),
    )
    return domain


def test_the_inspection_saves_the_state_with_its_fetch_date(domain, site):
    _with_urls(domain, site)
    google = FakeClient(inspect_url=response('url_inspection_indexada'))

    batch = inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)

    assert batch.state == BatchState.COMPLETED
    for url in Url.objects.filter(domain=domain):
        assert url.coverage_state == CoverageState.INDEXED
        assert url.last_checked_at is not None

    record = CoverageRecord.objects.first()
    assert record.fetched_at is not None
    assert record.raw_verdict == 'PASS'
    assert record.raw_coverage_state == 'Submitted and indexed'


def test_no_inspection_happens_without_a_granted_reservation(domain, site):
    """
    El doble protesta si lo llaman sin cupo, así que este test lo prueba de verdad.

    Es el invariante que el producto no puede romper: la cuota es del usuario y
    el sistema no la gasta sin haberla contado antes.
    """
    _with_urls(domain, site)
    google = FakeClient(inspect_url=response('url_inspection_indexada'))

    inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)

    assert google.calls
    for operation, reservation in google.calls:
        assert reservation.granted > 0, f'«{operation}» corrió sin cupo'


def test_the_spent_quota_matches_the_queries_made(domain, site):
    _with_urls(domain, site)
    google = FakeClient(inspect_url=response('url_inspection_indexada'))

    batch = inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)
    budget = budget_for(domain)

    assert batch.quota_consumed == 3
    assert budget.used_manual == 3


def test_without_quota_the_batch_ends_partial_and_not_completed(domain, site):
    """
    `PARTIAL` es obligatorio cuando el trabajo se cortó por cuota.

    Decir «terminado» con URLs sin consultar convierte una foto parcial del
    sitio en una afirmación sobre el sitio entero.
    """
    _with_urls(domain, site)
    # Se agota el cupo del día antes de empezar.
    reserve(domain, domain.automatic_budget)
    reserve(domain, domain.manual_reserve, origin='MANUAL')

    batch = inspect_domain(domain, origin=BatchOrigin.MANUAL, client=FakeClient())

    assert batch.state == BatchState.PARTIAL
    assert batch.summary['pending'] == 3
    assert batch.summary['quota_granted'] == 0
    assert Url.objects.filter(domain=domain, coverage_state=CoverageState.UNKNOWN).count() == 3


def test_the_quota_reserved_and_not_spent_comes_back(domain, site):
    """
    Un corte temprano no puede dejar al usuario sin consultas por el resto del día.

    Google no descontó nada de lo que no llegamos a pedir.
    """
    _with_urls(domain, site)
    google = FakeClient(inspect_url=http_error('error_permiso_denegado'))

    inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)
    budget = budget_for(domain)

    # Falló la primera y se corta: se gastó una, las otras dos se devuelven.
    assert budget.used_manual == 1


def test_the_history_only_records_the_changes(domain, site):
    """
    Un sitio grande consultado a diario generaría millones de filas idénticas.

    Lo que hay que poder responder es cuándo cambió algo, no cuántas veces se
    preguntó.
    """
    _with_urls(domain, site)
    google = FakeClient(inspect_url=response('url_inspection_indexada'))

    inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)
    after_the_first = CoverageRecord.objects.count()

    inspect_domain(domain, origin=BatchOrigin.MANUAL, client=google)

    assert after_the_first == 3
    assert CoverageRecord.objects.count() == 3


def test_a_state_change_is_recorded(domain, site):
    _with_urls(domain, site)

    inspect_domain(
        domain,
        origin=BatchOrigin.MANUAL,
        client=FakeClient(inspect_url=response('url_inspection_indexada')),
    )
    inspect_domain(
        domain,
        origin=BatchOrigin.MANUAL,
        client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
    )

    assert CoverageRecord.objects.count() == 6
    states = set(CoverageRecord.objects.values_list('state', flat=True))
    assert states == {CoverageState.INDEXED, CoverageState.CRAWLED_NOT_INDEXED}


def test_the_count_of_fallen_urls_keeps_growing_after_the_list_is_full(domain, site, monkeypatch):
    """
    La cifra es el total y la lista es una muestra: no son el mismo número.

    Es lo que le permite a la pantalla decir cuántas quedaron afuera. Cortar el
    contador junto con la lista haría que un sitio con mil caídas informara el
    tope como si fuera el total, y un recorte que se presenta como total es la
    afirmación falsa que este producto no puede cometer.
    """
    from apps.coverage import services as coverage_services

    monkeypatch.setattr(coverage_services, 'MAX_LOST_INDEXING_URLS', 2)

    _with_urls(domain, site)
    inspect_domain(
        domain,
        origin=BatchOrigin.MANUAL,
        client=FakeClient(inspect_url=response('url_inspection_indexada')),
    )
    batch = inspect_domain(
        domain,
        origin=BatchOrigin.MANUAL,
        client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
    )

    assert batch.summary['lost_indexing'] == 3
    assert len(batch.summary['lost_indexing_urls']) == 2


# --- Resumen ----------------------------------------------------------------


def test_the_urls_without_data_stay_out_of_the_percentage(domain, site):
    """
    Meterlas adentro convertiría «todavía no preguntamos» en «no está indexada».

    Es la mentira más fácil de cometer en este producto y la que el principio I
    prohíbe explícitamente.
    """
    _with_urls(domain, site)
    one = Url.objects.filter(domain=domain).first()

    inspect_domain(
        domain,
        origin=BatchOrigin.MANUAL,
        urls=[one],
        client=FakeClient(inspect_url=response('url_inspection_indexada')),
    )

    summary = coverage_summary(domain)

    assert summary['total'] == 3
    assert summary['with_data'] == 1
    assert summary['without_data'] == 2
    # Una de una con dato: cien por ciento. Si las sin consultar contaran, diría
    # treinta y tres, que sería una afirmación sobre URLs que nadie consultó.
    assert summary['indexed_percentage'] == 100.0


def test_without_any_queried_url_there_is_no_percentage(domain, site):
    """Nulo y no cero: un cero se leería como «ninguna está indexada»."""
    _with_urls(domain, site)

    summary = coverage_summary(domain)

    assert summary['with_data'] == 0
    assert summary['indexed_percentage'] is None


def test_the_urls_from_another_site_are_discarded_and_reported(domain):
    """
    Un sitemap puede declarar direcciones de cualquier sitio.

    Registrarlas gastaría el cupo del usuario consultando URLs sobre las que su
    propiedad de Search Console no tiene nada que decir, y Google devolvería un
    error por cada una. Se descartan y el resumen lo dice: descartarlas en
    silencio dejaría un conteo que no cierra con el sitemap.
    """
    register_sitemap(domain, 'https://ejemplo.test/sitemap-1.xml')
    with_foreign = (
        (FIXTURES / 'urlset.xml')
        .read_bytes()
        .replace(b'https://ejemplo.test/nota/dos', b'https://otro-sitio.test/nota/dos')
    )

    batch = sync_domain(
        domain,
        client=FakeClient(submit_sitemap={}),
        http=fake_site(**{'https://ejemplo.test/sitemap-1.xml': with_foreign}),
    )

    assert batch.summary['foreign_urls'] == 1
    assert not Url.objects.filter(domain=domain, loc__contains='otro-sitio.test').exists()
    assert Url.objects.filter(domain=domain).count() == 2
    assert any('no pertenecen' in error for error in batch.summary['errors'])


def test_a_subdomain_does_belong_to_a_domain_property(domain):
    """
    La propiedad de dominio cubre todos sus subdominios: es su definición.

    Descartarlos convertiría un sitemap legítimo de blog.ejemplo.test en un
    archivo entero de URLs rechazadas.
    """
    from apps.sitemaps.services import belongs_to_domain

    assert belongs_to_domain(domain, 'https://blog.ejemplo.test/nota')
    assert belongs_to_domain(domain, 'https://ejemplo.test/nota')
    assert not belongs_to_domain(domain, 'https://ejemplo.test.otro.com/nota')
    assert not belongs_to_domain(domain, 'https://otro.test/nota')
