"""Sweep nacional dos anúncios CIRE do CITIUS.

O `cire.py` pesquisa por NIF, um de cada vez, e só encontra o que envolve
empresas já registadas. Este módulo faz o inverso: varre **todos** os anúncios
publicados num intervalo de datas, sem filtro de entidade, para depois cruzar as
partes contra a watchlist. É isso que permite detectar a insolvência de um
devedor que ninguém tinha adicionado à plataforma — e o cruzamento é retroactivo,
porque os anúncios ficam guardados mesmo quando não dão match no momento.

Verificado ao vivo em 2026-07-25:
  * a pesquisa aceita termo vazio, filtrada só por datas (`dd-mm-aaaa`);
  * ~110 documentos/dia em todos os grupos, arquivo até pelo menos 2020;
  * paginação por `__EVENTTARGET=…Pager1$lnkNext`, 10 blocos por página;
  * cada bloco traz um `queryString` opaco que dá acesso ao PDF do anúncio,
    **desde que se use a mesma sessão HTTP da pesquisa** — com cookies novos o
    CITIUS devolve uma página de erro. Daí este módulo ser uma classe que segura
    o cliente, em vez de funções soltas.
"""
import logging
import re
from datetime import date
from typing import Any, AsyncIterator

import httpx

from app.scrapers.base import (
    build_client,
    extract_aspnet_state,
    http_get,
    http_post,
    random_delay,
)
from app.scrapers.cire_common import (
    BASE,
    F_ACTOS,
    F_ATE,
    F_BTN,
    F_DESDE,
    F_DIAS,
    F_GRUPO,
    F_NUMERO,
    F_PESQUISA,
    F_TIPO,
    F_TRIBUNAIS,
    URL,
    parse_cire_page,
    parse_pt_date,
    split_process,
)

logger = logging.getLogger(__name__)

PAGE_CAP = 400  # tecto de segurança: 400 páginas x 10 = 4000 anúncios por corrida

# Actos cujo PDF fixa um prazo de reclamação de créditos. Só para estes vale a
# pena puxar o PDF em todas as corridas — os restantes ficam a pedido, quando
# houver match. Guardar 110 PDFs/dia para os ~1% que interessam não compensa.
CLAIM_ACT_RE = re.compile(
    r"(sent(en[çc]a)?\s*(de\s*)?(declara[çc][ãa]o\s*)?.*insolv"
    r"|insolv.*sent(en[çc]a)?"
    r"|cita[çc][ãa]o\s*(de\s*)?credores"
    r"|credores\s*/\s*desp"
    r"|lista\s*provis[óo]ria\s*de\s*cr[ée]ditos"
    r"|verifica[çc][ãa]o\s*(ulterior\s*)?(de\s*)?cr[ée]ditos)",
    re.IGNORECASE,
)

IFRAME_SRC_RE = re.compile(r'<iframe[^>]*id="ifrDoc"[^>]*src="([^"]+)"')
LBL_TOTAL_RE = re.compile(r'id="lblTotal"[^>]*>(\d+)')


def _fmt(d: date) -> str:
    return d.strftime("%d-%m-%Y")


def opens_claim_window(ato: str | None) -> bool:
    """True quando o acto é do tipo que fixa prazo de reclamação de créditos."""
    return bool(ato and CLAIM_ACT_RE.search(ato))


def normalize_block(entry: dict[str, Any], token: str | None) -> dict[str, Any]:
    """Bloco parseado -> linha pronta a persistir. As partes ficam de fora do
    `raw` de propósito: vão para tabela própria e duplicá-las aqui custaria ~4x
    em disco (o `processes.raw` chega a 83 kB numa insolvência com muitos
    credores)."""
    parties = entry.get("_parties") or []
    raw = {k: v for k, v in entry.items() if k != "_parties"}
    process_number, juizo = split_process(entry.get("processo") or "")
    ato = entry.get("ato")
    pub_date = parse_pt_date(entry.get("data"))
    return {
        "referencia": entry.get("referência") or entry.get("referencia"),
        "tribunal": entry.get("tribunal"),
        "juizo": juizo,
        "ato": ato,
        "process_number": process_number or None,
        "species": entry.get("espécie") or entry.get("especie"),
        "pub_date": pub_date,
        "action_date": parse_pt_date(entry.get("data da propositura da ação")),
        "raw": raw,
        "parties": [
            {
                "role": (p.get("role") or "")[:100] or None,
                "name": (p.get("name") or "")[:500],
                "nif": (p.get("nif") or "").strip() or None,
            }
            for p in parties
            if (p.get("name") or "").strip()
        ],
        "pdf_token": token,
        "wants_pdf": opens_claim_window(ato),
    }


class CireAnnouncementSweeper:
    """Segura a sessão HTTP durante todo o sweep.

    Uso:
        async with CireAnnouncementSweeper() as s:
            async for page in s.pages(d1, d2):
                for row in page["rows"]:
                    if row["wants_pdf"]:
                        row["pdf_text"] = await s.fetch_pdf_text(row["pdf_token"])
    """

    def __init__(self, grupo: str = "") -> None:
        self.grupo = grupo
        self._client: httpx.AsyncClient | None = None

    async def __aenter__(self) -> "CireAnnouncementSweeper":
        self._client = build_client()
        await self._client.__aenter__()
        return self

    async def __aexit__(self, *exc: Any) -> None:
        if self._client is not None:
            await self._client.__aexit__(*exc)
            self._client = None

    @property
    def client(self) -> httpx.AsyncClient:
        if self._client is None:
            raise RuntimeError("CireAnnouncementSweeper usado fora do async with")
        return self._client

    async def pages(
        self, date_from: date, date_to: date
    ) -> AsyncIterator[dict[str, Any]]:
        """Percorre as páginas de resultados do intervalo, uma de cada vez, para
        o chamador poder persistir e commitar por página."""
        initial = await http_get(self.client, URL)
        payload = {
            **extract_aspnet_state(initial.text),
            F_TIPO: "nif",
            F_PESQUISA: "",
            F_DIAS: "todos",
            F_TRIBUNAIS: "",
            F_GRUPO: self.grupo,
            F_ACTOS: "",
            F_NUMERO: "",
            F_DESDE: _fmt(date_from),
            F_ATE: _fmt(date_to),
            F_BTN: "Pesquisar",
        }
        await random_delay()
        r = await http_post(self.client, URL, data=payload, headers={"Referer": URL})

        page = 1
        while True:
            data = parse_cire_page(r.text)
            tokens = data["tokens"]
            rows = [
                normalize_block(b, tokens[i] if i < len(tokens) else None)
                for i, b in enumerate(data["blocks"])
            ]
            yield {"page": page, "total": data["total"], "rows": rows}

            if not data["has_next"]:
                break
            if page >= PAGE_CAP:
                logger.warning(
                    "cire_announcements %s..%s: tecto de páginas atingido (%d)",
                    date_from, date_to, PAGE_CAP,
                )
                break
            pager = {
                **extract_aspnet_state(r.text),
                "__EVENTTARGET": "ctl00$ContentPlaceHolder1$Pager1$lnkNext",
                "__EVENTARGUMENT": "",
            }
            pager.pop(F_BTN, None)
            await random_delay()
            r = await http_post(self.client, URL, data=pager, headers={"Referer": URL})
            page += 1

    async def fetch_pdf_bytes(self, token: str | None) -> bytes | None:
        """Token do bloco -> bytes do PDF, ou None quando o anúncio não tem
        documento associado (`lblTotal` = 0, o que acontece em ~1/3 dos casos)."""
        if not token:
            return None
        try:
            viewer = await http_get(
                self.client, f"{BASE}Viewer/MostraPdf.aspx?q={token}",
                headers={"Referer": URL},
            )
        except Exception as e:  # noqa: BLE001 — nunca deixar o sweep cair por um PDF
            logger.info("cire_announcements: viewer falhou: %s", e)
            return None

        total = LBL_TOTAL_RE.search(viewer.text)
        if not total or int(total.group(1)) == 0:
            return None
        src = IFRAME_SRC_RE.search(viewer.text)
        if not src:
            return None

        url = BASE + "Viewer/" + src.group(1).replace("&amp;", "&")
        try:
            doc = await http_get(
                self.client, url, headers={"Referer": f"{BASE}Viewer/MostraPdf.aspx"}
            )
        except Exception as e:  # noqa: BLE001
            logger.info("cire_announcements: download do PDF falhou: %s", e)
            return None
        if not doc.content.startswith(b"%PDF"):
            return None
        return doc.content
