"""ARCHIVED — v1 distribuição scraper (per-company × per-tribunal).

Superseded on 2026-04-19 by the tribunal-sweep mode in ../distribuicao.py
(scrape_distribuicao_tribunal_sweep) that queries each tribunal ONCE without
a party filter and matches all monitored companies locally — reducing HTTP
pressure on CITIUS by ~50× for the daily cron.

Kept here for reference in case we need to revisit the per-company substring
search (e.g. if CITIUS adds server-side filters we want to reuse).

See also: ./scheduler_run_distribuicao_v1.py for the scheduler loop that drove
this scraper.
"""
import json
import logging
import re
import unicodedata
from datetime import date, datetime
from typing import Any

from bs4 import BeautifulSoup, Tag
from rapidfuzz import fuzz

from app.config import settings
from app.scrapers.base import (
    build_client,
    extract_aspnet_state,
    http_get,
    http_post,
    random_delay,
)

logger = logging.getLogger(__name__)

URL = "https://www.citius.mj.pt/portal/consultas/ConsultasDistribuicao.aspx"

F_TRIBUNAIS = "ctl00$ContentPlaceHolder1$ddlTribunais"
F_DESDE = "ctl00$ContentPlaceHolder1$txtCalendarDesde"
F_ATE = "ctl00$ContentPlaceHolder1$txtCalendarAte"
F_PARTE = "ctl00$ContentPlaceHolder1$txtParte"
F_NENTRADA = "ctl00$ContentPlaceHolder1$txtNEntrada"
F_BTN = "ctl00$ContentPlaceHolder1$btnSearch"

GRID_ID = "ctl00_ContentPlaceHolder1_grdView"
COUNT_RE = re.compile(r"(\d+)\s+Processos\s+encontrados", re.IGNORECASE)
DATE_RE = re.compile(r"(\d{1,2})/(\d{1,2})/(\d{4})")


def _parse_pt_date(s: str | None) -> date | None:
    if not s:
        return None
    m = DATE_RE.search(s)
    if not m:
        return None
    d, mm, y = m.groups()
    try:
        return datetime(int(y), int(mm), int(d)).date()
    except ValueError:
        return None


async def discover_tribunal_codes() -> list[dict[str, str]]:
    async with build_client() as client:
        r = await http_get(client, URL)
    soup = BeautifulSoup(r.text, "lxml")
    select = soup.find("select", {"name": F_TRIBUNAIS})
    out: list[dict[str, str]] = []
    if not select:
        return out
    for opt in select.find_all("option"):
        val = (opt.get("value") or "").strip()
        label = (opt.get_text() or "").strip()
        if val and val != "0" and label and not label.startswith("-"):
            out.append({"value": val, "label": label})
    return out


def _grid_label_by_suffix(row: Tag, suffix: str) -> str | None:
    for span in row.find_all("span"):
        sid = span.get("id") or ""
        if sid.endswith(suffix):
            return span.get_text(" ", strip=True) or None
    return None


def _parties_from_row(row: Tag) -> list[dict[str, str]]:
    parties: list[dict[str, str]] = []
    # Inside a span whose id ends with _DataList — each sub-span is one party
    datalist = None
    for span in row.find_all("span"):
        sid = span.get("id") or ""
        if sid.endswith("_DataList"):
            datalist = span
            break
    if not datalist:
        return parties
    for sub in datalist.find_all("span", recursive=False):
        role_span = None
        name_span = None
        for lbl in sub.find_all("span"):
            lid = lbl.get("id") or ""
            if "lblDesignacao" in lid:
                role_span = lbl
            elif "lblNomeInterv" in lid:
                name_span = lbl
        role = (role_span.get_text(strip=True).rstrip(":") if role_span else "") or ""
        name = (name_span.get_text(" ", strip=True) if name_span else "") or ""
        if name:
            parties.append({"role": role, "name": name})
    return parties


def parse_distribuicao_page(html: str) -> dict[str, Any]:
    soup = BeautifulSoup(html, "lxml")
    table = soup.find("table", id=GRID_ID)
    count_match = COUNT_RE.search(soup.get_text() or "")
    total = int(count_match.group(1)) if count_match else 0
    rows: list[dict[str, Any]] = []
    if table is None:
        return {"total": total, "rows": rows, "has_next": False}
    for tr in table.find_all("tr"):
        # Skip header row (has <th>)
        if tr.find("th"):
            continue
        cells = tr.find_all("td")
        if len(cells) < 3:
            continue
        entry: dict[str, Any] = {
            "process_number": _grid_label_by_suffix(tr, "_lblNProcesso"),
            "unorganica": _grid_label_by_suffix(tr, "_lblUnOrganica"),
            "especie": _grid_label_by_suffix(tr, "_lblEspecie"),
            "data_entrada": _grid_label_by_suffix(tr, "_lblDataEntrada"),
            "data_distribuicao": _grid_label_by_suffix(tr, "_lblDataDistrib"),
            "valor": _grid_label_by_suffix(tr, "_lblValor"),
            "observacoes": _grid_label_by_suffix(tr, "_lblObservacoes"),
            "parties": _parties_from_row(tr),
        }
        if entry["process_number"]:
            rows.append(entry)
    has_next = bool(soup.find(id="ctl00_ContentPlaceHolder1_Pager1_lnkNext"))
    return {"total": total, "rows": rows, "has_next": has_next}


def _best_match(
    parties: list[dict[str, str]],
    companies: list[dict[str, Any]],
    threshold: float,
) -> tuple[dict[str, Any], dict[str, str]] | None:
    """Return (company, matched_party) or None."""
    for party in parties:
        name = party.get("name") or ""
        if not name:
            continue
        best: tuple[float, dict[str, Any]] | None = None
        for c in companies:
            legal = c.get("legal_name") or ""
            if not legal:
                continue
            score = fuzz.token_set_ratio(name.lower(), legal.lower()) / 100.0
            if best is None or score > best[0]:
                best = (score, c)
        if best and best[0] >= threshold:
            return best[1], party
    return None


_CORP_SUFFIX = {
    "LDA", "LDA.", "SA", "S.A.", "UNIPESSOAL", "SOCIEDADE",
    "SGPS", "S.G.P.S.", "SOC.", "CRL", "CRL.", "S.R.L.",
    "INC", "INC.", "CORP", "CORP.",
}


MIN_SEARCH_CHARS = 3

# Corporate-form indicators that distinguish pessoas coletivas from homónimos
# humanos. Uses lowercase; callers lowercase the party name before checking.
# Include short forms (e.g. "suc.") because tribunals often abbreviate.
_CORP_INDICATORS = (
    " lda", ",lda", " ldª", " ld.",
    " s.a", " sa,", ",sa", " s a ", " s a,",
    "unipessoal",
    "sgps", "s.g.p.s",
    "sucursal", " suc.", " suc ",
    "sociedade", " soc.", " soc ",
    " crl", "crl.",
    "cooperativa",
    "s.r.l", " s.l.", " s.l ",
    " ltd", " ltda",
    "gmbh", " b.v.",
    "a.c.e", " ace ",
)


def _has_corp_indicator(name_lo: str) -> bool:
    return any(ind in name_lo for ind in _CORP_INDICATORS)


def _normalize(s: str) -> str:
    """Lowercase + strip diacritics. Makes "Segurança" match "Seguranca"
    and "Securité" match "securite". Tribunals sometimes drop accents."""
    if not s:
        return ""
    return "".join(
        ch for ch in unicodedata.normalize("NFD", s.lower())
        if unicodedata.category(ch) != "Mn"
    )


def is_company_party(party_name: str, distinctive: str) -> bool:
    """Does `party_name` refer to the monitored company identified by
    `distinctive`?

    Three conditions:
      1. After diacritic normalization, `party_name` *starts with* `distinctive`
         (the distintivo — e.g. "SEGUNOR", "IGPS PROTEK", "CARLOS SILVA").
         Using startswith (not substring anywhere) rejects names like
         "Medicina Laboratorial Carlos Silva Torres, S.A." where the company
         label appears deep inside an unrelated company name.
      2. `party_name` contains a corporate-form indicator (LDA, S.A., Sucursal,
         Unipessoal, SGPS, …).
      3. The character right after the distintivo is a word boundary (space,
         comma, dash, quote, end-of-string). This protects companies whose
         distintivo is a common prefix (e.g. "FIR" must not match "FIRMINO").

    Rejects homónimos humanos (individuals lack corp suffix) AND companies
    that merely *contain* the distintivo as part of a longer phrase.
    """
    if not party_name or not distinctive:
        return False
    name_norm = _normalize(party_name).lstrip(" \"'")
    dist_norm = _normalize(distinctive)
    if not name_norm.startswith(dist_norm):
        return False
    # Word-boundary after the distintivo: the next char must be non-alpha.
    tail = name_norm[len(dist_norm):]
    if tail and tail[0].isalpha():
        return False
    # Corp suffix check over the original lowercased name (indicator list is
    # accented-neutral already via "lda"/"sa"/"sucursal" etc. which have no
    # diacritics).
    return _has_corp_indicator(party_name.lower())


def short_search_term(legal_name: str) -> str:
    """Reduce a legal_name to a short, distinctive search term for Distribuição's
    substring filter.

    Strategy: take the part before the first separator ("-", "–", ","), drop
    corporate suffixes, then use the first token if it's long enough (>=6
    chars). Otherwise use the first two tokens. Short term = robust against
    spelling variations further down the name (e.g. Gardiennage vs Gradiennage).

    Returns "" when the derived term has fewer than MIN_SEARCH_CHARS
    non-whitespace chars — a too-short term (e.g. "J M" from "J M F L - …")
    would substring-match every "João Miguel", "Jorge Mendes", etc., creating
    tens of thousands of false positives. Callers must skip companies with an
    empty search term (or the user must add a distinctive alias).

    Examples:
      'SEGUNOR - SEGURANÇA PRIVADA, LDA'     -> 'SEGUNOR'
      'IGPS PROTEK INTERVENTION …'           -> 'IGPS PROTEK'
      'A & B, S.A.'                          -> 'A & B'
      'J M F L - SEGURANÇA …'                -> '' (too short/ambiguous — skip)
    """
    if not legal_name:
        return ""
    head = legal_name
    for sep in (" - ", " – ", ","):
        if sep in head:
            head = head.split(sep, 1)[0].strip()
            break
    tokens = [t for t in head.split() if t.upper().strip(",.") not in _CORP_SUFFIX]
    if not tokens:
        return ""
    # Multi-token head: ALWAYS use first 2 tokens. Using only the first (even
    # if it is ≥6 chars) lets homónimos sneak in via a middle name — e.g.
    # distintivo "CARLOS" matches "Carlos Meira Silva, Lda.", or distintivo
    # "INTEGRAL" matches "Instituto Piaget - … Integral e Ecológico, Crl".
    # Two-token contiguous substring (e.g. "CARLOS SILVA", "INTEGRAL SERVICOS")
    # rejects these.
    if len(tokens) >= 2:
        term = " ".join(tokens[:2])
        if len(term.replace(" ", "")) >= MIN_SEARCH_CHARS:
            return term
        # Still too short (e.g. "J M" = 2 chars); extend token by token.
        for n in range(3, len(tokens) + 1):
            term = " ".join(tokens[:n])
            if len(term.replace(" ", "")) >= MIN_SEARCH_CHARS:
                return term
        return ""
    # Single-token head (SEGUNOR, GIS, FIR …): accept if long enough. Corp-
    # suffix strict check later guards against common-word homónimos.
    tok = tokens[0]
    return tok if len(tok) >= MIN_SEARCH_CHARS else ""


async def scrape_distribuicao_range(
    tribunal_value: str,
    tribunal_label: str,
    date_from: date,
    date_to: date,
    companies: list[dict[str, Any]],
    party_filter: str | None = None,
) -> list[dict[str, Any]]:
    """Scrape one tribunal for a date range, return matches."""
    matches: list[dict[str, Any]] = []
    async with build_client() as client:
        initial = await http_get(client, URL)
        vs = extract_aspnet_state(initial.text)
        payload = {
            **vs,
            F_TRIBUNAIS: tribunal_value,
            F_DESDE: date_from.strftime("%d-%m-%Y"),
            F_ATE: date_to.strftime("%d-%m-%Y"),
            F_PARTE: party_filter or "",
            F_NENTRADA: "",
            F_BTN: "Pesquisar",
        }
        await random_delay()
        r = await http_post(client, URL, data=payload, headers={"Referer": URL})

        page = 1
        all_rows: list[dict[str, Any]] = []
        while True:
            parsed = parse_distribuicao_page(r.text)
            all_rows.extend(parsed["rows"])
            if not parsed["has_next"]:
                break
            if page >= 50:
                logger.warning(
                    "distribuicao %s: pagination safety cap at page %d", tribunal_label, page
                )
                break
            vs_next = extract_aspnet_state(r.text)
            pager_payload = {
                **vs_next,
                "__EVENTTARGET": "ctl00$ContentPlaceHolder1$Pager1$lnkNext",
                "__EVENTARGUMENT": "",
            }
            pager_payload.pop(F_BTN, None)
            await random_delay()
            r = await http_post(client, URL, data=pager_payload, headers={"Referer": URL})
            page += 1

    logger.info(
        "distribuicao tribunal=%s range=%s..%s party=%r -> %d rows (total parsed across pages)",
        tribunal_label, date_from, date_to, party_filter, len(all_rows),
    )

    for entry in all_rows:
        parties = entry.get("parties") or []
        if party_filter:
            # Server-side filter is loose (substring match) and catches many
            # homónimos humanos for common names like "Carlos Silva". Validate
            # locally: accept only if some party name contains the distintivo
            # AND a corporate-form indicator (LDA, S.A., Sucursal, …).
            matched_party = None
            for p in parties:
                if is_company_party(p.get("name") or "", party_filter):
                    matched_party = p
                    break
            if not matched_party:
                continue  # server hit was a false positive (e.g. individual homonym)
            company, party = companies[0], matched_party
        else:
            m = _best_match(parties, companies, settings.FUZZY_MATCH_THRESHOLD)
            if not m:
                continue
            company, party = m
        date_filed = (
            _parse_pt_date(entry.get("data_distribuicao"))
            or _parse_pt_date(entry.get("data_entrada"))
            or date_from
        )
        matches.append(
            {
                "company_id": company["id"],
                "source": "distribuicao",
                "process_number": entry["process_number"],
                "tribunal": tribunal_label,
                "juizo": entry.get("unorganica"),
                "species": entry.get("especie"),
                "role_in_process": (party.get("role") or "").strip() or None,
                "date_filed": date_filed,
                "raw_json": json.dumps(entry, ensure_ascii=False),
                "raw_html": None,
            }
        )
    return matches


async def scrape_distribuicao_for_day(
    tribunal_value: str,
    tribunal_label: str,
    day: date,
    companies: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    return await scrape_distribuicao_range(
        tribunal_value, tribunal_label, day, day, companies
    )
