"""Extração estruturada de um acto do registo comercial do IRN.

Substitui o `mj_people_parser`, que era tolerante de propósito e guardava tudo
como texto: a quota ia concatenada no `cargo` ("sócio — 125.000,00 Euros") e o
cargo saía de um varrimento de 600 caracteres para trás. Para um grafo de
participações isso não serve — a quota tem de ser um número comparável e o papel
tem de vir da secção onde a pessoa está.

Este módulo lê os layouts reais, guardados em `tests/fixtures/registry/`. O que
eles ensinam, e que não se adivinharia:

* **A linha do NIF vem indentada.** `NIF/NIPC:` aparece a seguir ao nome mas com
  um espaço à esquerda. O parser antigo exigia-a colada ao `\\n` e por isso
  devolvia zero pessoas em praticamente todo o corpus do IRN — falhou em silêncio
  durante meses. É a razão de existir deste ficheiro.
* **A secção das quotas tem dois nomes.** `SÓCIOS E QUOTAS:` numa constituição ou
  aumento, `QUOTA(S) E TITULAR(ES):` numa redução de capital.
* **Os montantes vêm em dois formatos.** `26.289,60` (pt-PT) e `15250.00` (ponto
  decimal, sem separador de milhares) coexistem no mesmo portal. Aplicar a regra
  portuguesa ao segundo daria 1.525.000 — um número plausível e cem vezes errado.
* **A ordem dos campos dentro do bloco varia.** Numa redução o `Estado civil` vem
  antes da `Nacionalidade`, num aumento não há `Nacionalidade` nenhuma. Portanto
  os campos lêem-se por etiqueta, não por posição.
* **O nome do titular traz a nacionalidade colada**: `TITULAR: FERNANDO JORGE
  BARBOSA DA COSTA, de Nacionalidade Portugusa` — com a gralha da fonte. Só se
  corta este sufixo concreto, porque as firmas têm vírgulas a sério
  (`CRESCENTIMOB - IMOBILIÁRIA, LDA`).
* **Os actos de capital dizem o capital resultante** (`Capital após o aumento`,
  `CAPITAL APÓS A REDUÇÃO`). É melhor fonte do que a constituição, que só dá o
  capital inicial — e resolve o caso da SEGUNOR, cujo capital ficava em branco por
  haver aumentos posteriores.
* **A mesma pessoa pode ser titular e gerente no mesmo acto** (numa constituição é
  a norma). São dois factos diferentes e saem como duas arestas.
"""
import re
import unicodedata
from datetime import date
from typing import Any

from app.services.mj_company_enrichment import html_to_text, parse_publication
from app.services.nif import is_valid_nif

# Sobe quando a extração muda de forma que valha reprocessar o corpus guardado.
# O `registry_acts.body` existe precisamente para isso poder ser feito sem rede.
#
# 2: passou a guardar-se o **valor** das quotas órfãs, não só quantas são. É o
#    que permite calcular percentagens sobre o capital do acto em vez de sobre a
#    soma das quotas que têm titular — na GÁLIA, 0,13% cada em vez de 20% cada.
# 3: cada bloco do corpo passa a levar a **data da sua própria apresentação**. Um
#    acto de rectificação reimprime uma inscrição antiga, e datá-la pela data de
#    publicação ressuscitava factos enterrados: o Luís Miguel de Sousa Leal saiu
#    da IGPS PROTEK em 2010 e um "Rectificado" de 2015 republicou a designação
#    dele de 2008, tornando-a o facto mais recente do processo.
# 4: um titular com **várias quotas** escreve-as todas antes do nome, e só a
#    última era atribuída — as outras iam para "órfãs". São 21.364 quotas em
#    14.035 actos, e é o que punha a PROFIVE com dois sócios de 2017 a 50% em vez
#    da Vanda com 150.000 e do Pedro com 100.000.
REGISTRY_PARSE_VERSION = 4

# ---------------------------------------------------------------- classificação

_ACT_CLASSES: tuple[tuple[str, re.Pattern[str]], ...] = (
    ("constituicao", re.compile(r"CONSTITUI[ÇC][ÃA]O\s+DE\s+SOCIEDADE", re.I)),
    ("transmissao_quotas", re.compile(r"(TRANSMISS[ÃA]O|CESS[ÃA]O)\s+DE\s+QUOTAS?", re.I)),
    ("aumento_capital", re.compile(r"AUMENTO\s+D[OE]\s+CAPITAL", re.I)),
    ("reducao_capital", re.compile(r"REDU[ÇC][ÃA]O\s+D[OE]\s+CAPITAL", re.I)),
    # Duas coisas de uma vez. O `[ÕO]ES?` só casava o plural — "Alteração ao
    # contrato", no singular, nunca casou desde o primeiro dia. E o IRN escreve
    # "Alteração **do** contrato de sociedade para sociedade unipessoal", que são
    # 8.062 actos a mudar a cap table e a ficar de fora por causa da preposição.
    # Uma sociedade plural que passa a unipessoal é, literalmente, sócios a sair
    # — e a transformação em anónima faz as quotas deixarem de existir. Ambas
    # ficavam de fora por não dizerem "alteração ao contrato".
    #
    # O `CONVERSÃO` seco fica deliberadamente de fora: no registo comercial
    # "Convertido" e "Transitada em julgado - convertida" são conversões de
    # processo de insolvência, e são 2.336 actos que não têm nada que ver com
    # estrutura societária.
    ("alteracao_contrato", re.compile(
        r"ALTERA[ÇC](?:[ÃA]O|[ÕO]ES)\s+(?:INTEGRAL\s+)?(?:AO|A|D[OA]|DOS|DAS|DE)\s+"
        r"(?:CONTRATO|ESTATUTOS?)"
        r"|MODIFICA[ÇC][ÃA]O\s+DE\s+SOCIEDADE"
        r"|TRANSFORMA[ÇC][ÃA]O\s+EM\s+SOCIEDADE"
        # "Rectificado quanto aos sócios e quotas" diz no título o que rectifica,
        # e por isso entra. O `Rectificado` seco continua de fora: são milhares e
        # metade não traz estrutura nenhuma.
        r"|(?:RECTIFICA|RETIFICA)\w*\s+QUANTO\s+A\w*\s+(?:VALOR\s+D\w+\s+)?"
        r"(?:S[ÓO]CIO|QUOTA|CAPITAL)", re.I)),
    ("cessacao", re.compile(
        r"CESSA[ÇC][ÃA]O\s+DE\s+FUN[ÇC][ÕO]ES"
        r"|ENCERRAMENTO\s+DA\s+REPRESENTA[ÇC][ÃA]O", re.I)),
    # `DE MEMBRO` deixava de fora "Designação de órgão(s) social(ais)", as
    # reconduções e os representantes — e "representante" é exactamente o tipo de
    # acto da IGPS PROTEK. A fonte alterna `órgão`/`orgão`, com e sem acento.
    # `DE` sozinho deixava de fora "Designação **do** secretário".
    ("designacao", re.compile(
        r"(DESIGNA[ÇC][ÃA]O|NOMEA[ÇC][ÃA]O|RECONDU[ÇC][ÃA]O)\s+D[EO]S?\s+"
        r"(MEMBROS?|[ÓO]RG[ÃA]OS?|REPRESENTANTE|SECRET[ÁA]RIO)"
        r"|ALTERA[ÇC][ÃA]O\s+DA\s+REPRESENTA[ÇC][ÃA]O", re.I)),
    ("dissolucao", re.compile(r"DISSOLU[ÇC][ÃA]O|LIQUIDA[ÇC][ÃA]O", re.I)),
    ("prestacao_contas", re.compile(r"PRESTA[ÇC][ÃA]O\s+DE\s+CONTAS", re.I)),
    ("mudanca_sede", re.compile(r"MUDAN[ÇC]A\s+D[AE]\s+SEDE", re.I)),
    ("fusao", re.compile(r"FUS[ÃA]O|CIS[ÃA]O", re.I)),
    ("insolvencia", re.compile(r"INSOLV[ÊE]NCIA", re.I)),
)

# Classes que trazem a estrutura de capital completa. Serve para a fila de
# recolha decidir prioridades **antes** de abrir o conteúdo — o título vem na
# listagem. Não decide se o acto *tem* cap table: isso só se sabe depois de
# parsear, e é `parse_quotas > 0` que manda (uma alteração ao contrato que só
# mudou a sede não tem titulares, e tratá-la como fotografia vazia apagaria os
# sócios da empresa).
CAPTABLE_CLASSES = frozenset(
    {"constituicao", "alteracao_contrato", "aumento_capital", "reducao_capital",
     "transmissao_quotas", "fusao"}
)
PEOPLE_CLASSES = CAPTABLE_CLASSES | {"designacao", "cessacao", "dissolucao", "insolvencia"}


def classify_act(act_type: str | None) -> str:
    if not act_type:
        return "outro"
    for name, pattern in _ACT_CLASSES:
        if pattern.search(act_type):
            return name
    return "outro"


def wants_content(act_type: str | None) -> bool:
    """Vale a pena gastar três chamadas HTTP a abrir este acto?"""
    return classify_act(act_type) in PEOPLE_CLASSES


# ------------------------------------------------------------------ utilitários

_NAT_SUFFIX_RE = re.compile(r",\s*de\s+Nacionalidade\b.*$", re.I)
# `X, SROC, nº 66, representada por Fulano, ROC nº 1838` são **dois** factos
# colados num nome só: a firma e quem a representa. O nó ficava com 160
# caracteres, a caixa do diagrama corta aos 26, e várias entidades diferentes
# apareciam com a mesma etiqueta. São 172 entidades assim, todas com NIF —
# portanto a `entity_key` delas vem do NIF e não se mexe ao mudar o nome.
_REPRESENTED_BY_RE = re.compile(r"[,;]?\s*represent[ao]d[ao]\s+por\b.*$", re.I)
# O número de inscrição da SROC vem a seguir à firma e não faz parte dela.
_ROC_NUMBER_RE = re.compile(r"[,;]\s*n\.?º\s*\d+\s*$", re.I)
_MONEY_RE = re.compile(r"(\d[\d.,\s]*\d|\d)\s*(Euros?|EUR|€|Escudos?|PTE)?", re.I)
_CURRENCY = {
    "euro": "EUR", "euros": "EUR", "eur": "EUR", "€": "EUR",
    "escudo": "PTE", "escudos": "PTE", "pte": "PTE",
}


def parse_money(raw: str | None) -> tuple[float | None, str | None]:
    """Devolve (valor, moeda). Os dois formatos que a fonte usa:

    * `26.289,60 Euros` — ponto de milhares, vírgula decimal (pt-PT)
    * `15250.00 Euros`  — ponto decimal, sem separador de milhares

    A regra que os distingue: um único ponto seguido de exactamente dois dígitos
    é decimal; tudo o resto são milhares. Assim `15250.00` dá 15250,00 e `1.000`
    dá 1000 — que é o que a fonte quer dizer em cada caso.

    Escudos não se convertem para euros. Uma constituição de 1998 diz
    `5.000.000,00 Escudos`, e somar isso com euros na mesma cap table produziria
    um total absurdo. A moeda viaja com o valor e quem soma verifica.
    """
    if not raw:
        return None, None
    m = _MONEY_RE.search(raw)
    if not m:
        return None, None
    digits = re.sub(r"\s", "", m.group(1))
    unit = (m.group(2) or "").strip().lower()
    currency = _CURRENCY.get(unit)

    if "," in digits:
        digits = digits.replace(".", "").replace(",", ".")
    elif digits.count(".") == 1 and len(digits.split(".")[1]) == 2:
        pass  # ponto decimal, deixa estar
    else:
        digits = digits.replace(".", "")
    try:
        return float(digits), currency
    except ValueError:
        return None, None


def normalize_name(name: str | None) -> str:
    """Minúsculas, sem acentos, espaços colapsados. Chave de comparação de nomes
    e base do `entity_key` de quem não tem NIF."""
    if not name:
        return ""
    folded = unicodedata.normalize("NFD", name.lower())
    folded = "".join(c for c in folded if unicodedata.category(c) != "Mn")
    folded = re.sub(r"[^a-z0-9]+", " ", folded)
    return re.sub(r"\s+", " ", folded).strip()


_ADDR_ABBREV = (
    (re.compile(r"\br\.?\s", re.I), "rua "),
    (re.compile(r"\bav\.?\s", re.I), "avenida "),
    (re.compile(r"\btrav\.?\s", re.I), "travessa "),
    (re.compile(r"\bpc\.?\s|\bpra[çc]a\b", re.I), "praca "),
    (re.compile(r"\blg\.?\s", re.I), "largo "),
    (re.compile(r"\bn\.?[ºo°]\s*", re.I), "n "),
    (re.compile(r"\besq\.?\b", re.I), "esquerdo"),
    (re.compile(r"\bdt[ºo°]?\.?\b", re.I), "direito"),
)


def normalize_address(street: str | None, postal: str | None = None) -> str | None:
    """Morada em forma comparável, para a resolução de identidade.

    Não é para mostrar — é para responder à pergunta "esta `Andreia Filipa
    Batista Balsemão Barbosa` que aparece como cônjuge é a mesma sócia
    `ANDREIA FILIPA B. B. BARBOSA` que está noutra empresa?". O nome sozinho não
    chega (homónimos foi o que estragou as relações antigas); o nome mais a rua e
    o código postal já chegam para uma sugestão honesta.
    """
    if not street and not postal:
        return None
    base = normalize_name(street or "")
    for pattern, repl in _ADDR_ABBREV:
        base = pattern.sub(repl, base)
    base = re.sub(r"\s+", " ", base).strip()
    if postal:
        base = f"{base} {postal.strip()}".strip()
    return base or None


def entity_kind(nif: str | None) -> str:
    """Tipo de entidade pelo primeiro dígito do NIF.

    1/2/3 pessoas singulares · 5 pessoas colectivas residentes · 6 sector
    público · 8 empresário em nome individual · 98 não residentes.

    Dois casos que não se confundem, e ambos ficam **fora da travessia do
    grafo**:

    * `unknown` — não há NIF. Acontece nas publicações de 2006 importadas pela
      extensão, escritas em prosa. O nome e a quota valem para mostrar na ficha
      da empresa, mas um nó só com nome não se segue para lado nenhum sem se
      inventar uma identidade.
    * `invalid` — há NIF mas falha o mod-11. Um dígito trocado num acto não pode
      fundir duas pessoas reais.
    """
    if not nif:
        return "unknown"
    if not is_valid_nif(nif):
        return "invalid"
    head = nif[0]
    if head in "1238":
        return "person"
    if head == "6":
        return "public"
    if nif.startswith("98"):
        return "foreign"
    return "company"


# Não são entidades: são estados da própria quota. Uma quota amortizada pertence
# à sociedade, e criar um sócio chamado "AMORTIZADAS" seria inventar um titular.
# Contam para o total (é por isso que a soma bate com o capital), mas não geram
# aresta.
_OWN_QUOTA_RE = re.compile(
    r"^\s*(quotas?\s+)?(amortizada|amortizadas|pr[óo]pria|pr[óo]prias|"
    r"a\s+sociedade|da\s+sociedade|sociedade)\s*$", re.I
)


def is_own_quota(name: str | None) -> bool:
    return bool(name) and bool(_OWN_QUOTA_RE.match(name.strip()))


def entity_key(nif: str | None, name: str | None) -> str | None:
    """Chave do nó. `nif:` quando há NIF, `name:` quando não há.

    Um `nif UNIQUE` deixaria passar NULLs sem limite e cada acto criaria um nó
    novo para o mesmo titular estrangeiro sem NIF.
    """
    if nif:
        return f"nif:{nif}"
    norm = normalize_name(name)
    return f"name:{norm}" if norm else None


# --------------------------------------------------------------------- secções

# A linha que abre cada bloco traz a apresentação que lhe deu origem:
# `AP. 2/20081110 15:00:05 UTC - CRIAÇÃO DE REPRESENTAÇÃO PERMANENTE`. O prefixo
# era deitado fora com um `.*?`; passa a ser guardado, porque é ali que está a data
# a que os factos daquele bloco realmente pertencem.
_ACT_HEADER_RE = re.compile(r"^(?P<prefix>[^\n]*?)UTC\s*-\s*(?P<act>[^\n]+)$", re.MULTILINE)
_AP_DATE_RE = re.compile(r"AP\.\s?\d+/(\d{8})")
# A apresentação do **próprio** acto, no preâmbulo: "pela Apresentação AP. 1/20150826".
_ACT_OWN_AP_RE = re.compile(r"pelas?\s+Apresenta[^\n]*\n?\s*AP\.\s?\d+/(\d{8})", re.IGNORECASE)


def _ap_date(fragment: str | None) -> date | None:
    """A data de uma apresentação `AP. n/AAAAMMDD`, se lá estiver."""
    m = _AP_DATE_RE.search(fragment or "")
    if not m:
        return None
    raw = m.group(1)
    try:
        return date(int(raw[:4]), int(raw[4:6]), int(raw[6:8]))
    except ValueError:
        return None
_SECTION_RE = re.compile(r"^[ \t]*(?P<label>[^\n:]{3,80}?)[ \t]*:[ \t]*$", re.MULTILINE)
_QUOTA_SECTION_RE = re.compile(
    r"(?:S[ÓO]CIOS\s+E\s+QUOTAS|QUOTA\(S\)\s+E\s+TITULAR\(ES\)|"
    r"S[ÓO]CIO\(S\)\s+E\s+QUOTA\(S\)|TITULAR\(ES\)\s+E\s+QUOTA\(S\))\s*:",
    re.I,
)
_ORGAN_LABELS = re.compile(
    r"GER[ÊE]NCIA|CONSELHO\s+DE\s+ADMINISTRA[ÇC][ÃA]O|ADMINISTRA[ÇC][ÃA]O|"
    r"FISCAL\s+[ÚU]NICO|CONSELHO\s+FISCAL|REVISOR|SECRET[ÁA]RIO|"
    r"DIRE[ÇC][ÃA]O|MESA\s+DA\s+ASSEMBLEIA|LIQUIDAT[ÁA]RIO|SUPLENTE",
    re.I,
)

_FIELD_RES = {
    "nif": re.compile(r"NIF\s*/\s*NIPC\s*:\s*(\d{9})\b", re.I),
    "cargo": re.compile(r"Cargo\s*:\s*([^\n]+)", re.I),
    "causa": re.compile(r"Causa\s*:\s*([^\n]+)", re.I),
    "data": re.compile(r"^[ \t]*Data\s*:\s*([^\n]+)", re.I | re.MULTILINE),
    "nacionalidade": re.compile(r"Nacionalidade\s*:\s*([^\n]+)", re.I),
    "estado_civil": re.compile(r"Estado\s+civil\s*:\s*([^\n]+)", re.I),
    "conjuge": re.compile(r"Nome\s+do\s+c[ôo]njuge\s*:\s*([^\n]+)", re.I),
    "regime_bens": re.compile(r"Regime\s+de\s+bens\s*:\s*([^\n]+)", re.I),
    "residencia": re.compile(r"Resid[êe]ncia\s*/\s*Sede\s*:\s*([^\n]+)", re.I),
    "pais": re.compile(r"Pa[íi]s\s+de\s+registo\s*:\s*([^\n]+)", re.I),
}
_POSTAL_RE = re.compile(r"^[ \t]*(\d{4})(?:\s*-\s*(\d{3}))?[ \t]+([^\n]+)$", re.MULTILINE)
_CAPITAL_AFTER_RE = re.compile(
    r"CAPITAL\s+AP[ÓO]S\s+(?:O\s+AUMENTO|A\s+REDU[ÇC][ÃA]O|A\s+ALTERA[ÇC][ÃA]O)?\s*:?\s*"
    r"([\d.,\s]+)\s*(Euros?|Escudos?|EUR|PTE)",
    re.I,
)
_CAPITAL_RE = re.compile(r"^[ \t]*CAPITAL\s*:\s*([\d.,\s]+)\s*(Euros?|Escudos?|EUR|PTE)", re.I | re.MULTILINE)
_DEPOSITARIO_RE = re.compile(
    r"NIF\s+do\s+deposit[áa]rio\s*:\s*(?P<nif>\d{9}).*?"
    r"Nome\s+do\s+deposit[áa]rio\s*:\s*(?P<name>[^\n]+)",
    re.I | re.DOTALL,
)


def _field(block: str, key: str) -> str | None:
    m = _FIELD_RES[key].search(block)
    if not m:
        return None
    value = m.group(1).strip()
    return value or None


def clean_entity_name(raw: str | None) -> str | None:
    """O nome da entidade, sem o que a fonte lhe cola atrás.

    Três sufixos, e nenhum é parte do nome: a nacionalidade
    (`, de Nacionalidade Portugusa` — com a gralha da fonte), o representante de
    uma sociedade de revisores (`, representada por Fulano, ROC nº 1838`) e o
    número de inscrição da própria SROC (`, nº 66`). As aspas soltas com que a
    fonte às vezes embrulha a firma também saem.

    O representante não se perde: continua no corpo do acto, que fica guardado.
    """
    if not raw:
        return None
    name = _NAT_SUFFIX_RE.sub("", raw)
    name = _REPRESENTED_BY_RE.sub("", name)
    name = _ROC_NUMBER_RE.sub("", name)
    name = name.strip().strip('"').strip().strip(",").strip()
    return name[:300] or None


def _postal_from(block: str) -> tuple[str | None, str | None]:
    """Código postal e localidade da linha logo abaixo da residência."""
    m = _POSTAL_RE.search(block)
    if not m:
        return None, None
    cp = f"{m.group(1)}-{m.group(2)}" if m.group(2) else m.group(1)
    return cp, m.group(3).strip() or None


def _split_acts(text: str) -> list[tuple[str, date | None, str]]:
    """Um corpo pode conter mais do que um acto. Devolve (título, data da
    apresentação, corpo) por acto; se não houver cabeçalho `UTC -`, devolve o
    corpo todo com título vazio e sem data.
    """
    matches = list(_ACT_HEADER_RE.finditer(text))
    if not matches:
        return [("", None, text)]
    out: list[tuple[str, date | None, str]] = []
    for i, m in enumerate(matches):
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        out.append((m.group("act").strip(), _ap_date(m.group("prefix")), text[m.end():end]))
    return out


def _section_bounds(segment: str, start: int) -> int:
    """Fim de uma secção: o próximo cabeçalho de secção depois de `start`.

    É isto que impede um titular sem NIF de roubar o NIF do titular seguinte, e
    um bloco de gerência de apanhar o `Cargo:` de outro órgão.
    """
    for m in _SECTION_RE.finditer(segment, start):
        label = m.group("label").strip()
        if not label:
            continue
        letters = [c for c in label if c.isalpha()]
        if letters and sum(1 for c in letters if c.isupper()) / len(letters) >= 0.7:
            return m.start()
    return len(segment)


def _parse_captable(segment: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Titulares e **quotas órfãs** — as que a fonte lista sem titular.

    **Um titular com várias quotas escreve-as todas antes do nome.** A PROFIVE
    lista `QUOTA: 125.000` e `QUOTA: 25.000` e só depois `TITULAR: Vanda`: são as
    duas dela, 150.000 no total. A versão anterior deste parser agarrava só a
    quota colada ao `TITULAR:` e mandava as outras para órfãs.

    Medido no corpus inteiro: de 24.703 quotas classificadas como órfãs, **21.364
    são seguidas de um titular** e portanto pertencem-lhe. Só 814 vêm depois do
    último titular e 2.525 estão em blocos que não nomeiam ninguém — essas sim,
    são órfãs.

    O erro custou caro e durante muito tempo, porque o caso que parecia prová-lo
    era ele próprio um sintoma: no `510036899` a "quota órfã" de 202.999,00 é a
    quota principal da GESTION ENERGETICA DE LA BIOMASA, que aparece três linhas
    abaixo com 10.684,21. Somadas dão 213.683,21 e fecham o capital ao cêntimo. O
    maior sócio nunca se perdia — era o parser que lho tirava.
    """
    m = _QUOTA_SECTION_RE.search(segment)
    if not m:
        return [], []
    body = segment[m.end():_section_bounds(segment, m.end())]

    chunks = re.split(r"(?=^[ \t]*QUOTA\s*:)", body, flags=re.MULTILINE)
    out: list[dict[str, Any]] = []
    orphans: list[dict[str, Any]] = []
    # Quotas já lidas cujo titular ainda não apareceu. Ficam à espera do próximo
    # `TITULAR:`; as que sobrarem no fim é porque não têm mesmo dono.
    pendentes: list[dict[str, Any]] = []
    for chunk in chunks:
        if not re.match(r"^[ \t]*QUOTA\s*:", chunk):
            continue
        quota_m = re.search(r"QUOTA\s*:\s*([^\n]+)", chunk, re.I)
        amount, currency = parse_money(quota_m.group(1) if quota_m else None)
        raw = quota_m.group(1).strip()[:80] if quota_m else None
        if "TITULAR" not in chunk.upper():
            pendentes.append({
                "quota_amount": amount,
                "quota_currency": currency,
                "quota_raw": raw,
            })
            continue
        name_m = re.search(r"TITULAR\s*:\s*([^\n]*)", chunk, re.I)
        name = clean_entity_name(name_m.group(1) if name_m else None)
        nif = _field(chunk, "nif")
        if not name and not nif:
            # O titular está lá escrito mas não se percebe quem é; as pendentes
            # não passam a ser dele por estarem por perto.
            orphans.extend(pendentes)
            pendentes.clear()
            continue
        residencia = _field(chunk, "residencia")
        cp, localidade = _postal_from(chunk)
        pessoa = {
            "name": name,
            "nif": nif,
            "kind": entity_kind(nif),
            "is_own_quota": is_own_quota(name),
            "nacionalidade": _field(chunk, "nacionalidade"),
            "estado_civil": _field(chunk, "estado_civil"),
            "conjuge": clean_entity_name(_field(chunk, "conjuge")),
            "regime_bens": _field(chunk, "regime_bens"),
            "residencia": residencia,
            "codigo_postal": cp,
            "localidade": localidade,
            "address_norm": normalize_address(residencia, cp),
            "pais": _field(chunk, "pais"),
        }
        # As pendentes primeiro, para a ordem ser a da publicação. Uma linha por
        # quota e não por sócio — é o que o modelo pede, e a ficha da empresa já
        # soma por titular.
        for extra in [*pendentes, {"quota_amount": amount, "quota_currency": currency, "quota_raw": raw}]:
            out.append({**pessoa, "seq": len(out), **extra})
        pendentes.clear()
    orphans.extend(pendentes)
    return out, orphans


def _parse_organs(segment: str) -> list[dict[str, Any]]:
    """Membros de órgãos sociais, com o cargo tirado da secção onde estão.

    Cada bloco começa em `Nome/Firma:` e acaba no próximo `Nome/Firma:` ou no
    fim da secção — sem isto o `Cargo:` de um membro colava-se ao anterior.
    """
    out: list[dict[str, Any]] = []
    organ_positions = [
        (m.start(), m.group("label").strip())
        for m in _SECTION_RE.finditer(segment)
        if _ORGAN_LABELS.search(m.group("label"))
    ]

    def organ_for(pos: int) -> str | None:
        current = None
        for start, label in organ_positions:
            if start <= pos:
                current = label
            else:
                break
        return current

    starts = [m.start() for m in re.finditer(r"^[ \t]*Nome/Firma\s*:", segment, re.I | re.MULTILINE)]
    for i, start in enumerate(starts):
        end = starts[i + 1] if i + 1 < len(starts) else len(segment)
        block = segment[start:end]
        name_m = re.search(r"Nome/Firma\s*:\s*([^\n]*)", block, re.I)
        name = clean_entity_name(name_m.group(1) if name_m else None)
        nif = _field(block, "nif")
        if not name and not nif:
            continue
        residencia = _field(block, "residencia")
        cp, localidade = _postal_from(block)
        out.append({
            "name": name,
            "nif": nif,
            "kind": entity_kind(nif),
            "cargo": (_field(block, "cargo") or organ_for(start) or None),
            "organ": organ_for(start),
            "causa": _field(block, "causa"),
            "data": _field(block, "data"),
            "nacionalidade": _field(block, "nacionalidade"),
            "estado_civil": _field(block, "estado_civil"),
            "conjuge": clean_entity_name(_field(block, "conjuge")),
            "regime_bens": _field(block, "regime_bens"),
            "residencia": residencia,
            "codigo_postal": cp,
            "localidade": localidade,
            "address_norm": normalize_address(residencia, cp),
        })
    return out


def _parse_depositario(segment: str) -> list[dict[str, Any]]:
    m = _DEPOSITARIO_RE.search(segment)
    if not m:
        return []
    nif = m.group("nif")
    name = clean_entity_name(m.group("name"))
    return [{
        "name": name, "nif": nif, "kind": entity_kind(nif),
        "cargo": "depositário", "organ": None, "causa": None, "data": None,
        "nacionalidade": None, "estado_civil": None, "conjuge": None,
        "regime_bens": None, "residencia": None, "codigo_postal": None,
        "localidade": None, "address_norm": None,
    }]


def _capital(segment: str) -> tuple[float | None, str | None, bool]:
    """Capital do acto e se é o valor **resultante** (mais fiável) ou o inicial.

    Os actos de capital publicam `Capital após o aumento` / `CAPITAL APÓS A
    REDUÇÃO` — o valor actual. A constituição publica `CAPITAL :`, que é o
    inicial e envelhece. Distinguir os dois é o que permite mostrar capital
    actual sem inventar: a SEGUNOR foi constituída com 1.000 EUR e as quotas já
    são de 125.000 cada.
    """
    m = _CAPITAL_AFTER_RE.search(segment)
    if m:
        amount, currency = parse_money(f"{m.group(1)} {m.group(2)}")
        return amount, currency, True
    m = _CAPITAL_RE.search(segment)
    if m:
        amount, currency = parse_money(f"{m.group(1)} {m.group(2)}")
        return amount, currency, False
    return None, None, False


def parse_act(body: str | None, act_type: str | None = None) -> dict[str, Any]:
    """Tudo o que um acto diz, em forma estruturada.

    `act_type` é o título que vem da listagem; quando falta, tira-se do
    cabeçalho `UTC -` do próprio corpo.
    """
    text = body or ""
    out: dict[str, Any] = {
        "parse_version": REGISTRY_PARSE_VERSION,
        "header": parse_publication(text) if text else {},
        "act_type": act_type,
        "act_class": classify_act(act_type),
        "capital": None,
        "capital_currency": None,
        "capital_is_resulting": False,
        "quotas": [],
        "officers": [],
        "quota_orphans": [],
        "quota_total": None,
        "quota_currencies": [],
        "captable_complete": False,
        "captable_eligible": False,
        "captable_linkable": False,
        "counts": {"quotas": 0, "people": 0, "orphans": 0},
    }
    if not text.strip():
        return out

    segments = _split_acts(text)
    if not act_type and segments and segments[0][0]:
        out["act_type"] = segments[0][0]
        out["act_class"] = classify_act(segments[0][0])

    # A apresentação do próprio acto, para se saber quais dos blocos são dele e
    # quais são reimpressões de inscrições anteriores. Quem decide a data final é o
    # `registry_ingest`, que é quem tem o `act_date` à mão.
    m_own = _ACT_OWN_AP_RE.search(text)
    out["act_own_ap"] = _ap_date(m_own.group(0)) if m_own else None

    quotas: list[dict[str, Any]] = []
    orphans: list[dict[str, Any]] = []
    officers: list[dict[str, Any]] = []
    for title, seg_ap, segment in segments:
        seg_class = classify_act(title) if title else out["act_class"]
        found, found_orphans = _parse_captable(segment)
        if found or found_orphans:
            # Um acto posterior no mesmo corpo substitui a fotografia anterior.
            quotas = found
            orphans = found_orphans
        seg_officers = _parse_organs(segment)
        if seg_class == "dissolucao":
            seg_officers.extend(_parse_depositario(segment))
        # Cada facto leva a data do bloco onde foi encontrado e se esse bloco é uma
        # cessação. O `act_class` do acto inteiro não serve: há corpos com um bloco
        # de cessação e outro de designação, e marcá-los todos com a mesma natureza
        # ora fecha quem entrou ora mantém quem saiu.
        for person in seg_officers:
            person["ap_date"] = seg_ap
            person["cessation"] = seg_class == "cessacao"
        officers.extend(seg_officers)
        for quota in found + found_orphans:
            quota["ap_date"] = seg_ap
        amount, currency, resulting = _capital(segment)
        if amount is not None and (resulting or out["capital"] is None):
            out["capital"] = amount
            out["capital_currency"] = currency
            out["capital_is_resulting"] = resulting

    for i, q in enumerate(quotas):
        q["seq"] = i
    out["quotas"] = quotas
    out["officers"] = officers
    out["quota_orphans"] = orphans
    out["counts"] = {"quotas": len(quotas), "people": len(officers), "orphans": len(orphans)}

    everything = quotas + orphans
    currencies = {q["quota_currency"] for q in everything if q["quota_currency"]}
    out["quota_currencies"] = sorted(currencies)
    # Só se soma quando há uma moeda só. Misturar escudos com euros daria um
    # total plausível e errado, e é melhor não ter total do que ter esse. As
    # órfãs entram no total: são quotas reais, só não têm titular nomeado.
    if everything and len(currencies) <= 1 and all(q["quota_amount"] is not None for q in everything):
        out["quota_total"] = round(sum(q["quota_amount"] for q in everything), 2)

    # A cap table só se diz completa quando a soma das quotas bate com o capital
    # declarado no mesmo acto. Medido no corpus guardado: bate em 163 de 171
    # actos onde ambos existem. Os que não batem são estruturas parciais — o acto
    # só listou as quotas que mexeram — e é isso que este sinal serve para dizer,
    # em vez de apresentar uma parte como se fosse o todo.
    out["captable_complete"] = bool(
        out["quota_total"] is not None
        and out["capital"] is not None
        and abs(out["quota_total"] - out["capital"]) < 0.02
    )
    # Sem um único NIF na cap table não há nada que se ligue ao grafo: é o caso
    # das publicações de 2006 em prosa. As quotas continuam a valer para a ficha
    # da empresa; simplesmente não geram travessia.
    out["captable_linkable"] = any(q["nif"] for q in quotas)
    # Elegível para *ser* a estrutura actual da empresa. Uma quota órfã invalida
    # o acto para esse efeito: substituir a cap table conhecida por uma que
    # deixa de fora um titular que a própria fonte não nomeia seria trocar uma
    # verdade parcial por uma afirmação errada.
    out["captable_eligible"] = bool(
        quotas and not orphans and all(q["quota_amount"] is not None for q in quotas)
    )
    return out


def parse_act_html(html: str | None, act_type: str | None = None) -> dict[str, Any]:
    return parse_act(html_to_text(html), act_type)
