"""Publicações societárias via o portal novo do IRN.

Substitui o `mj_publications.py`, que estava dormente desde abril: o
`publicacoes.mj.pt` tem reCAPTCHA **e** AJAX Control Toolkit NoBot, e nem os
POSTs que não envolvem captcha passam. Não vale a pena insistir nesse caminho.

O IRN migrou a pesquisa para uma app OutSystems em
`meu.registo.justica.gov.pt/empresa/pesquisapublica`, cujos screenservices
devolvem JSON **sem captcha, a partir de IP de datacenter** — testado do próprio
CT sobre as 94 empresas monitorizadas, 94/94 sem erros, 2105 publicações.

Isto torna obsoletos o proxy residencial (~€3-10/mês) e a extensão Chrome que
obrigava alguém a navegar o portal à mão.

## Protocolo

1. `GET moduleservices/moduleinfo` -> `manifest.versionToken` = moduleVersion.
   Lido em runtime porque muda a cada deploy do IRN.
2. Um POST a qualquer screenservice devolve **403 mas estabelece a sessão
   anónima** (cookies `nr1Users`/`nr2Users`).
3. O token CSRF vem **dentro** do cookie `nr2Users` (`crf=…`, url-encoded) e vai
   no header `X-CSRFToken`. Sem ele tudo devolve 403 "Invalid Login" — é o
   detalhe que destranca o acesso.

Cada resposta traz `versionInfo.hasApiVersionChanged`; quando vier `true`, o IRN
fez deploy e os `apiVersion` abaixo têm de ser reextraídos de
`/EOL/scripts/EOL.PublicSearchFlow.PublicSearchBlock.mvc.js`.
"""
import json
import logging
import urllib.parse
from datetime import date
from typing import Any

import httpx

from app.scrapers.base import random_delay

logger = logging.getLogger(__name__)

BASE = "https://meu.registo.justica.gov.pt/empresa/"
SS = BASE + "screenservices/EOL/PublicSearchFlow/"
VIEW_SEARCH = "PublicSearchFlow.PublicSearch"
VIEW_CONTENT = "PublicSearchFlow.PublicationContent"

API = {
    "publications": ("PublicSearchBlock/ActionGetPublications_BL", "Nb1USv39LhQg2W+YLlRcZQ"),
    "site_properties": ("PublicSearchBlock/DataActionFetchSiteProperties", "YTd4bJSKvOnEVq2gKCSdrg"),
    "content_wrapper": ("PublicSearchBlock/ActionContentOnClickWrapper", "wDBJUImUiVj7A6B9lyWJTg"),
    "fetch_inputs": ("PublicationContent/DataActionFetchInputs", "1rg35SzC9Ko_++rqVMb2TQ"),
    "content": ("PublicationContentBlock/ActionGetContentByPublicationId", "dpgHT4hwrImeMWiGxr8jNQ"),
}

# Sem filtro de datas. Intervalos explícitos estão limitados a 10 dias pelo
# serviço, e o que queremos por NIF é o histórico completo.
NO_DATE = "1900-01-01T00:00:00"
EMPTY_CODE = {"Code": "", "Label": "", "GenericCodeStructure": ""}
# O tipo de publicação TEM de ter Id válido: com Id 0 o conector devolve 400.
TYPE_ALL = {"Id": 7, "Label": "", "Order": 0, "Codigo": "TODOS", "GUID": ""}
PAGE_SIZE = 20  # o Index é um offset, múltiplo de 20


class IrnVersionChanged(RuntimeError):
    """O IRN fez deploy e os apiVersion codificados aqui ficaram obsoletos."""


class MjIrnClient:
    """Segura a sessão anónima e o token CSRF durante uma corrida."""

    def __init__(self, timeout: float = 45.0) -> None:
        self._client: httpx.AsyncClient | None = None
        self._timeout = timeout
        self._module_version: str | None = None
        self._csrf: str | None = None

    async def __aenter__(self) -> "MjIrnClient":
        self._client = httpx.AsyncClient(
            timeout=httpx.Timeout(self._timeout, connect=15.0),
            headers={
                "User-Agent": (
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
                    "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
                ),
                "Accept-Language": "pt-PT,pt;q=0.9",
            },
            follow_redirects=True,
        )
        await self._handshake()
        return self

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

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

    async def _handshake(self) -> None:
        info = await self.client.get(BASE + "moduleservices/moduleinfo")
        info.raise_for_status()
        self._module_version = info.json()["manifest"]["versionToken"]

        # Este pedido devolve 403 de propósito: é o que faz nascer a sessão
        # anónima e o cookie de onde sai o CSRF.
        path, api = API["site_properties"]
        await self._raw_post(path, api, VIEW_SEARCH, {}, csrf=None)

        raw = self.client.cookies.get("nr2Users") or ""
        decoded = urllib.parse.unquote(raw)
        parts = dict(
            p.split("=", 1) for p in decoded.split(";") if "=" in p
        )
        self._csrf = parts.get("crf")
        if not self._csrf:
            raise RuntimeError("IRN: não foi possível obter o token CSRF do cookie nr2Users")
        logger.debug("IRN handshake ok (moduleVersion=%s)", self._module_version)

    async def _raw_post(
        self, path: str, api_version: str, view: str, inputs: dict[str, Any], csrf: str | None
    ) -> httpx.Response:
        headers = {
            "Content-Type": "application/json; charset=UTF-8",
            "Accept": "application/json",
            "Referer": BASE + "pesquisapublica",
            "Origin": "https://meu.registo.justica.gov.pt",
            "X-Requested-With": "XMLHttpRequest",
        }
        if csrf:
            headers["X-CSRFToken"] = csrf
        body = {
            "versionInfo": {"moduleVersion": self._module_version, "apiVersion": api_version},
            "viewName": view,
            "screenData": {"variables": {}},
            "inputParameters": inputs,
        }
        return await self.client.post(SS + path, content=json.dumps(body), headers=headers)

    async def call(self, key: str, inputs: dict[str, Any], view: str = VIEW_SEARCH) -> dict[str, Any]:
        path, api_version = API[key]
        r = await self._raw_post(path, api_version, view, inputs, csrf=self._csrf)
        r.raise_for_status()
        data = r.json()

        if data.get("versionInfo", {}).get("hasApiVersionChanged"):
            raise IrnVersionChanged(
                f"o IRN mudou o apiVersion de {key}; reextrair de "
                "/EOL/scripts/EOL.PublicSearchFlow.PublicSearchBlock.mvc.js"
            )
        if "exception" in data:
            raise RuntimeError(f"IRN {key}: {data['exception'].get('message')}")
        return data.get("data", {})

    async def publications_for_nif(self, nif: str, max_pages: int = 30) -> list[dict[str, Any]]:
        """Todas as publicações de um NIPC, do histórico completo."""
        out: list[dict[str, Any]] = []
        index = 0
        for _ in range(max_pages):
            data = await self.call(
                "publications",
                {
                    "Filters": {
                        "NIF_NIPC": nif,
                        "StartDate": NO_DATE,
                        "EndDate": NO_DATE,
                        "Entity": "",
                        "District": EMPTY_CODE,
                        "Council": EMPTY_CODE,
                        "PublicationType": TYPE_ALL,
                    },
                    "Index": index,
                },
            )
            rows = (data.get("Results") or {}).get("List") or []
            out.extend(rows)
            total = int(data.get("TotalCount") or 0)
            index += PAGE_SIZE
            if index >= total or not rows:
                break
            await random_delay()
        return out

    async def publications_for_day(
        self, day: date, max_pages: int = 200
    ) -> list[dict[str, Any]]:
        """Todos os actos publicados **no país** num dia.

        É a via que responde à pergunta inversa — "em que outras empresas é que
        esta pessoa é sócia" — porque a pesquisa por NIF só funciona com NIPC de
        entidade: com o NIF de um indivíduo o serviço devolve zero, medido.

        **Um dia de cada vez, com `StartDate == EndDate`.** É o único modo fiável:
        intervalos explícitos devolvem contagens absurdas (13 a 17 de julho de
        2026 → 112 292 actos, quando cada dia útil ronda os 600) e sem filtro de
        datas devolve zero. Medido: 618 actos num dia útil, 31 páginas, 3,9 s;
        ~20 num fim de semana.
        """
        stamp = f"{day.isoformat()}T00:00:00"
        out: list[dict[str, Any]] = []
        index = 0
        for _ in range(max_pages):
            data = await self.call(
                "publications",
                {
                    "Filters": {
                        "NIF_NIPC": "",
                        "StartDate": stamp,
                        "EndDate": stamp,
                        "Entity": "",
                        "District": EMPTY_CODE,
                        "Council": EMPTY_CODE,
                        "PublicationType": TYPE_ALL,
                    },
                    "Index": index,
                },
            )
            rows = (data.get("Results") or {}).get("List") or []
            out.extend(rows)
            total = int(data.get("TotalCount") or 0)
            index += PAGE_SIZE
            if index >= total or not rows:
                break
        return out

    async def content_for(self, pub: dict[str, Any]) -> str | None:
        """HTML do conteúdo da publicação.

        São três passos porque o portal passa o contexto por estado de sessão:
        o wrapper devolve um `Resource` opaco, o ecrã de conteúdo consome-o, e
        só depois o conteúdo fica acessível.
        """
        try:
            wrapper = await self.call(
                "content_wrapper",
                {
                    "PublicationId": pub["Id"],
                    "Date": pub.get("Date"),
                    "NIF_NIPC": pub.get("NIF_NIPC"),
                    "Entity": pub.get("Entity"),
                    "HasDocument": bool(pub.get("Documents")),
                },
            )
            resource = wrapper.get("Resource")
            if not resource:
                return None
            await self.call("fetch_inputs", {"Resource": resource}, view=VIEW_CONTENT)
            data = await self.call(
                "content",
                {"PublicationId": pub["Id"], "IsDoc": False},
                view=VIEW_CONTENT,
            )
            detalhes = ((data.get("Results") or {}).get("Data") or {}).get("Detalhes") or {}
            return detalhes.get("Content") or None
        except IrnVersionChanged:
            raise
        except Exception as e:  # noqa: BLE001 — uma publicação sem conteúdo não pode parar a corrida
            logger.info("IRN: conteúdo de %s falhou: %s", pub.get("Id"), e)
            return None
