"""Parse the HTML of a publicacoes.mj.pt search results page.

The portal renders matched publications in a simple HTML table with a fixed
header row:

  | Data | NIF/NIPC | Entidade | Concelho | Acto/Facto | [Conteúdo] | ... |

Each data row maps 1:1 to one publication event. We identify the header
row by its labels, learn the column indexes, then walk every following row
that has at least 5 cells and a 9-digit NIF in the NIF column.

The "Acto/Facto" cell is the semantic title: "Designação de membro(s) de
orgão(s) social(ais)", "Cessação de funções…", "Alterações ao contrato de
sociedade", "PRESTAÇÃO DE CONTAS INDIVIDUAL", etc. The classifier downstream
maps those to HIGH/MEDIUM/LOW relevance via its regex patterns.

The full publication body lives behind a "Conteúdo" button that triggers
an ASP.NET postback — we don't follow those here; the row-level data is
what populates the timeline.
"""
import logging
import re
from datetime import date, datetime
from typing import Any

from bs4 import BeautifulSoup, Tag

logger = logging.getLogger(__name__)

_DATE_RE = re.compile(r"(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})[/-](\d{1,2})[/-](\d{4})")
_NIPC_RE = re.compile(r"^\d{9}$")

_HEADER_LABELS = {
    "data": "date",
    "nif": "nif",
    "nif/nipc": "nif",
    "nipc": "nif",
    "entidade": "entity",
    "concelho": "concelho",
    "acto/facto": "acto",
    "ato/facto": "acto",
    "acto": "acto",
    "ato": "acto",
}


def parse_detail_html(html: str) -> dict[str, Any]:
    """Parse /DetalhePublicacao.aspx HTML into a dict with:
      * body      — full visible text of the "Publicação" container
      * fields    — key-value pairs extracted from the header block
      * acto      — the specific DEP act title (e.g. 'PRESTAÇÃO DE CONTAS INDIVIDUAL')
    Non-fatal: returns empty dict on parse failure.
    """
    if not html:
        return {}
    soup = BeautifulSoup(html, "lxml")

    # The detail container is usually the page body minus the site chrome.
    # Grab the whole text first and slice away boilerplate edges.
    body_text = soup.get_text("\n", strip=True)
    lines = [_normalise(l) for l in body_text.split("\n") if _normalise(l)]

    # Drop header/footer boilerplate (Fachada, Help Desk, Linha Registos etc).
    drop_prefixes = (
        "fachada", "ministério da justiça", "o link seguinte",
        "help desk", "help-desk", "linha registos",
        "publicações de atos societários",
    )
    trimmed: list[str] = []
    for l in lines:
        low = l.lower()
        if any(low.startswith(p) for p in drop_prefixes):
            continue
        trimmed.append(l)

    # Field extraction — the detail page lists labeled values at the top.
    fields: dict[str, str] = {}
    label_map = {
        "nif/nipc": "nipc",
        "nipc": "nipc",
        "entidade": "entity",
        "data publicação": "pub_date",
        "firma": "firma",
        "natureza jurídica": "natureza",
        "sede": "sede",
        "distrito": "distrito",
        "concelho": "concelho",
        "freguesia": "freguesia",
        "matriculada na": "matricula",
    }
    for i, l in enumerate(trimmed):
        # Labels followed by value on same line separated by spaces/tabs, or
        # on the next line in some layouts.
        for label, key in label_map.items():
            if l.lower().startswith(label):
                rest = l[len(label):].lstrip(": ").strip()
                if rest:
                    fields.setdefault(key, rest)
                elif i + 1 < len(trimmed):
                    fields.setdefault(key, trimmed[i + 1])

    # "DEP 1367/2018-07-19 01:15:30 UTC - PRESTAÇÃO DE CONTAS INDIVIDUAL"
    dep_m = re.search(
        r"DEP\s+(\S+)\s+[\d:\s]+UTC\s*-\s*(.+?)$",
        "\n".join(trimmed), re.M | re.I,
    )
    acto = dep_m.group(2).strip() if dep_m else None

    body_compact = "\n".join(trimmed).strip()
    return {
        "body": body_compact[:8000],  # trim to sane size
        "fields": fields,
        "acto": acto,
    }


def _parse_pt_date(s: str | None) -> date | None:
    if not s:
        return None
    s = s.strip()
    m = _DATE_RE.search(s)
    if not m:
        return None
    if m.group(1):  # yyyy-mm-dd
        y, mm, d = m.group(1), m.group(2), m.group(3)
    else:  # dd/mm/yyyy or dd-mm-yyyy
        d, mm, y = m.group(4), m.group(5), m.group(6)
    try:
        return date(int(y), int(mm), int(d))
    except ValueError:
        return None


def _normalise(s: str) -> str:
    return re.sub(r"\s+", " ", s).strip()


def _cell_text(td: Tag) -> str:
    """Text of a <td>, ignoring button labels ("Conteúdo") and icons."""
    # Drop buttons/links whose own text is just UI noise
    for junk in td.find_all(["button", "a"]):
        txt = _normalise(junk.get_text(" "))
        if txt.lower() in ("conteúdo", "conteudo", "ver", "detalhe"):
            junk.extract()
    return _normalise(td.get_text(" "))


def _find_header_columns(table: Tag) -> dict[str, int] | None:
    """Scan the first row that has labels matching the known headers; return
    a {semantic_name: column_index} map. Returns None if no header found."""
    for tr in table.find_all("tr"):
        cells = tr.find_all(["th", "td"])
        if len(cells) < 4:
            continue
        labels = [_normalise(c.get_text(" ")).lower() for c in cells]
        idx_map: dict[str, int] = {}
        for i, lbl in enumerate(labels):
            key = _HEADER_LABELS.get(lbl)
            if key and key not in idx_map:
                idx_map[key] = i
        # Must have at least date + acto to be a useful table; NIF+entity
        # very helpful when present
        if "date" in idx_map and "acto" in idx_map:
            return idx_map
    return None


def parse_mj_html(html: str, nipc_hint: str | None = None) -> list[dict[str, Any]]:
    """Extract every publication row from a publicacoes.mj.pt results page.

    `nipc_hint`: when provided, only rows matching this NIPC are kept. In
    practice the user searches by NIPC so every row on the page matches
    anyway — the hint is a safety net, not strictly required."""
    soup = BeautifulSoup(html, "lxml")
    items: list[dict[str, Any]] = []
    seen: set[tuple[str | None, str]] = set()

    for table in soup.find_all("table"):
        cols = _find_header_columns(table)
        if not cols:
            continue
        date_idx = cols["date"]
        acto_idx = cols["acto"]
        nif_idx = cols.get("nif")
        entity_idx = cols.get("entity")
        concelho_idx = cols.get("concelho")

        for tr in table.find_all("tr"):
            cells = tr.find_all(["td"])
            if len(cells) <= max(date_idx, acto_idx):
                continue
            # Skip header row (has <th> instead of <td> in well-formed tables,
            # but some deployments use <td> for header too — fall back to
            # recognising "Data" as literal text).
            first_text = _normalise(cells[date_idx].get_text(" ")).lower()
            if first_text in ("data", "nif", "nipc", "nif/nipc"):
                continue

            date_text = _cell_text(cells[date_idx])
            acto_text = _cell_text(cells[acto_idx])
            if not date_text or not acto_text:
                continue
            d = _parse_pt_date(date_text)
            if not d:
                continue  # drop rows where the date cell isn't a real date

            nif = None
            if nif_idx is not None and nif_idx < len(cells):
                candidate = _cell_text(cells[nif_idx])
                if _NIPC_RE.match(candidate):
                    nif = candidate
            if nipc_hint and nif and nif != nipc_hint:
                continue

            entity = (
                _cell_text(cells[entity_idx])
                if entity_idx is not None and entity_idx < len(cells)
                else None
            )
            concelho = (
                _cell_text(cells[concelho_idx])
                if concelho_idx is not None and concelho_idx < len(cells)
                else None
            )

            key = (d.isoformat(), acto_text.lower())
            if key in seen:
                continue
            seen.add(key)

            # Build the structured row. Summary keeps the human-readable
            # trail (entity + concelho + acto) so the classifier regex can
            # match against it.
            summary_parts = [
                p for p in (entity, concelho, acto_text) if p
            ]
            items.append({
                "title": acto_text[:500],
                "summary": " · ".join(summary_parts)[:2000],
                "date": d,
                "type": acto_text[:120],
                "source_url": None,  # "Conteúdo" button is a postback, not addressable
                "source": "mj",
                "nipc": nif,
                "raw": {
                    "date": date_text,
                    "nif": nif,
                    "entity": entity,
                    "concelho": concelho,
                    "acto": acto_text,
                },
            })

    return items
