#!/usr/bin/env python3
"""Parse NuBank PDF text extract into movimentos.json for INPUSH rebuild."""
from __future__ import annotations

import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path

MESES = {
    "JAN": 1, "FEV": 2, "MAR": 3, "ABR": 4, "MAI": 5, "JUN": 6,
    "JUL": 7, "AGO": 8, "SET": 9, "OUT": 10, "NOV": 11, "DEZ": 12,
}

DATE_RE = re.compile(
    r"^(\d{2})\s+(" + "|".join(MESES.keys()) + r")\s+(20\d{2})\b"
)

# Ordered longest-first to avoid partial matches
TX_TYPES = [
    "Transferência recebida pelo Pix",
    "Transferência Recebida",
    "Transferência enviada pelo Pix",
    "Valor adicionado na conta por cartão de crédito",
    "Valor adicionado na conta por",
    "Pagamento de boleto devolvido",
    "Pagamento de boleto efetuado",
    "Pagamento de fatura",
    "Pagamento Recebido",
    "Compra no débito",
    "Compra no crédito",
    "Depósito recebido",
    "Depósito via boleto",
    "Estorno de compra no débito",
    "Estorno de",
    "Estorno",
    "Rendimento",
    "Tarifa",
    "Cashback",
    "Aplicação RDB",
    "Resgate RDB",
    "Saque",
]

SKIP_PREFIXES = (
    "Total de entradas",
    "Total de saídas",
    "Saldo do dia",
    "Saldo inicial",
    "Saldo final",
    "Rendimento líquido",
    "Tem alguma dúvida",
    "Caso a solução",
    "Extrato gerado",
    "INPUSH",
    "CNPJ ",
    "16 DE JULHO",
    "VALORES EM",
    "Movimentações",
    "Agência:",
    "UNIBANCO",
    "PAGAMENTOS",
)

# NuBank transaction lines show unsigned amounts; leading "-" is a name separator, not a sign.
MONEY_RE = re.compile(r"(\d{1,3}(?:\.\d{3})*,\d{2})\s*$")
CNPJ_RE = re.compile(r"\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}")
CNPJ_BROKEN_RE = re.compile(
    r"(\d{2}\.\d{3}\.\d{3}/\d{4}-)\s*(\d{1,3}(?:\.\d{3})*,\d{2})?\s*(\d{2})\b"
)
CPF_MASK_RE = re.compile(r"•••\.\d{3}\.\d{3}-••")


def parse_money(s: str) -> float:
    s = s.strip().replace(" ", "").replace(".", "").replace(",", ".")
    return abs(float(s))


def normalize_name(name: str) -> str:
    name = re.sub(r"\s+", " ", name).strip(" -–")
    return name


def classify_sentido(tipo: str) -> str:
    t = tipo.lower()
    if "boleto devolvido" in t:
        return "C"
    if any(x in t for x in [
        "enviada", "pagamento de boleto efetuado", "pagamento de fatura",
        "compra no", "tarifa", "aplicação", "saque",
    ]):
        return "D"
    if "estorno" in t and "compra" in t:
        return "C"
    if any(x in t for x in [
        "recebida", "recebido", "valor adicionado", "rendimento",
        "cashback", "depósito", "resgate",
    ]):
        return "C"
    if "estorno" in t:
        return "C"
    return "D"


def is_noise(line: str) -> bool:
    s = line.strip()
    if not s:
        return True
    if DATE_RE.match(s):
        return True
    for p in SKIP_PREFIXES:
        if s.startswith(p) or s.startswith("Total de"):
            return True
    if re.match(r"^\d+\s+de\s+\d+$", s):
        return True
    if s.startswith("R$"):
        return True
    return False


def find_tx_type(line: str) -> str | None:
    stripped = line.strip()
    for t in TX_TYPES:
        if stripped.startswith(t) or f"  {t}" in line or stripped.lower().startswith(t.lower()):
            # case-insensitive startswith for variants
            idx = stripped.lower().find(t.lower())
            if idx == 0:
                return t
    # fuzzy: type may have irregular spacing from PDF
    low = stripped.lower()
    for t in TX_TYPES:
        if low.startswith(t.lower()):
            return t
    return None


def extract_amount_and_rest(text: str) -> tuple[float | None, str]:
    m = MONEY_RE.search(text)
    if not m:
        return None, text.strip()
    amount = parse_money(m.group(1))
    rest = text[: m.start()].strip()
    return amount, rest


def extract_docs(blob: str) -> tuple[str | None, str | None]:
    cnpj = None
    m = CNPJ_RE.search(blob)
    if m:
        cnpj = m.group(0)
    else:
        # CNPJ quebrado: 40.215.418/0001- 17.000,00 30
        mb = CNPJ_BROKEN_RE.search(blob)
        if mb:
            cnpj = mb.group(1) + mb.group(3)
    cpf_mask = None
    m2 = CPF_MASK_RE.search(blob)
    if m2:
        cpf_mask = m2.group(0)
    return cnpj, cpf_mask


def counterparty_from_rest(tipo: str, rest: str, continuation: str) -> str:
    blob = (rest + " " + continuation).strip()
    for t in TX_TYPES:
        if blob.lower().startswith(t.lower()):
            blob = blob[len(t):].strip()
            break
    # Prefer text before first " - " that looks like a document/bank separator
    parts = re.split(r"\s+-\s+", blob)
    name = parts[0].strip() if parts else blob
    name = CNPJ_RE.sub("", name)
    name = CPF_MASK_RE.sub("", name)
    name = re.sub(r"\d{2}\.\d{3}\.\d{3}/\d{4}-\s*$", "", name)
    name = re.sub(
        r"\s+(ITAÚ|ITAU|NU\b|BCO|BANCO|COOP|SICREDI|CAIXA|SANTANDER|BRADESCO|99PAY).*$",
        "",
        name,
        flags=re.I,
    )
    name = normalize_name(name)
    if tipo.startswith("Pagamento de fatura"):
        name = name or "Fatura NuBank"
    if tipo.startswith("Valor adicionado"):
        name = "Valor adicionado Pix no Crédito"
    if tipo.startswith("Tarifa"):
        name = name or "Tarifa NuBank"
    if tipo == "Rendimento":
        name = "Rendimento NuBank"
    return name or "Desconhecido"


def parse_lines(lines: list[str]) -> list[dict]:
    movimentos: list[dict] = []
    current_date: str | None = None
    i = 0
    n = len(lines)

    while i < n:
        line = lines[i]
        raw = line.rstrip("\n")
        stripped = raw.strip()

        dm = DATE_RE.match(stripped)
        if dm:
            d, mon, y = dm.group(1), dm.group(2), dm.group(3)
            current_date = f"{y}-{MESES[mon]:02d}-{int(d):02d}"
            i += 1
            continue

        if current_date is None:
            i += 1
            continue

        tx = find_tx_type(stripped)
        if not tx:
            i += 1
            continue

        if tx == "Valor adicionado na conta por":
            tx = "Valor adicionado na conta por cartão de crédito"

        amount, rest_after_type = extract_amount_and_rest(stripped)
        for t in TX_TYPES:
            if rest_after_type.lower().startswith(t.lower()):
                rest_after_type = rest_after_type[len(t):].strip()
                break

        j = i + 1
        cont = []
        while j < n and j < i + 8:
            nxt = lines[j].strip()
            if not nxt:
                j += 1
                continue
            if DATE_RE.match(nxt) or find_tx_type(nxt):
                break
            if nxt.startswith("Total de") or nxt.startswith("Saldo do dia"):
                break
            # skip pure footer/header noise unless it carries CNPJ/agency
            if any(nxt.startswith(p) for p in (
                "Tem alguma", "Caso a solução", "Extrato gerado", "INPUSH",
                "CNPJ ", "16 DE JULHO", "VALORES EM",
            )):
                break
            if nxt.lower().startswith("cartão de crédito"):
                cont.append(nxt)
                j += 1
                continue
            cont.append(nxt)
            if amount is None:
                a2, _ = extract_amount_and_rest(nxt)
                if a2 is not None:
                    amount = a2
            j += 1

        if amount is None:
            i += 1
            continue

        cont_text = " ".join(cont)
        full_blob = stripped + " " + cont_text
        cnpj, cpf_mask = extract_docs(full_blob)
        name = counterparty_from_rest(tx, rest_after_type, cont_text)
        sentido = classify_sentido(tx)

        movimentos.append({
            "data": current_date,
            "tipo_banco": tx,
            "sentido": sentido,
            "valor": round(abs(amount), 2),
            "contraparte": name,
            "cnpj": cnpj,
            "cpf_mask": cpf_mask,
            "raw": normalize_name(full_blob)[:300],
        })
        i = j if j > i + 1 else i + 1

    # Rendimento líquido aparece só no resumo do PDF (+4,22), fora das linhas de movimento.
    if movimentos:
        last_date = max(m["data"] for m in movimentos)
        movimentos.append({
            "data": last_date,
            "tipo_banco": "Rendimento",
            "sentido": "C",
            "valor": 4.22,
            "contraparte": "Rendimento NuBank",
            "cnpj": None,
            "cpf_mask": None,
            "raw": "Rendimento líquido +4,22 (resumo do extrato)",
            "synthetic": True,
        })

    return movimentos


def main() -> int:
    src = Path(sys.argv[1] if len(sys.argv) > 1 else "out/extrato.txt")
    out_dir = Path(sys.argv[2] if len(sys.argv) > 2 else "out")
    out_dir.mkdir(parents=True, exist_ok=True)

    text = src.read_text(encoding="utf-8", errors="replace")
    lines = text.splitlines()
    movimentos = parse_lines(lines)

    # stable hash for idempotency
    for idx, m in enumerate(movimentos):
        key = f"{m['data']}|{m['sentido']}|{m['valor']:.2f}|{normalize_name(m['contraparte']).upper()}"
        m["hash"] = f"{key}|{idx}"
        m["hash_base"] = key

    entradas = sum(m["valor"] for m in movimentos if m["sentido"] == "C" and not m.get("synthetic"))
    saidas = sum(m["valor"] for m in movimentos if m["sentido"] == "D")
    rendimento = sum(m["valor"] for m in movimentos if m.get("synthetic") and m["tipo_banco"] == "Rendimento")
    saldo = round(entradas - saidas + rendimento, 2)

    expected = {
        "entradas": 708068.00,
        "saidas": 706619.62,
        "saldo_final": 1452.60,
        "rendimento": 4.22,
    }

    report = {
        "source": str(src),
        "count": len(movimentos),
        "totais": {
            "entradas": round(entradas, 2),
            "saidas": round(saidas, 2),
            "rendimento": round(rendimento, 2),
            "saldo": saldo,
        },
        "expected": expected,
        "diff": {
            "entradas": round(entradas - expected["entradas"], 2),
            "saidas": round(saidas - expected["saidas"], 2),
            "saldo": round(saldo - expected["saldo_final"], 2),
        },
        "por_tipo": dict(Counter(m["tipo_banco"] for m in movimentos)),
        "top_contrapartes": Counter(m["contraparte"] for m in movimentos).most_common(40),
        "cnpjs_unicos": sorted({m["cnpj"] for m in movimentos if m["cnpj"]}),
    }

    (out_dir / "movimentos.json").write_text(
        json.dumps(movimentos, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    (out_dir / "parse_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
    )

    print(json.dumps(report["totais"], ensure_ascii=False))
    print("diff:", json.dumps(report["diff"]))
    print("count:", report["count"])
    print("por_tipo:", json.dumps(report["por_tipo"], ensure_ascii=False, indent=2))
    ok = (
        abs(report["diff"]["entradas"]) < 1.0
        and abs(report["diff"]["saidas"]) < 1.0
        and abs(report["diff"]["saldo"]) < 5.0
    )
    print("VALID:", ok)
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
