#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
VarejoTech — Segundo Cérebro · mapear-contexto.py

Lê o cérebro inteiro e produz um RAIO-X das ligações entre as notas:

  1. inventário   — quantas notas, onde, com e sem cabeçalho (frontmatter)
  2. arestas      — quem cita quem (links no corpo + campos `relacionado*`)
  3. problemas    — links quebrados, notas órfãs, cabeçalho faltando
  4. candidatos   — pares de notas que provavelmente têm relação e ainda não
                    estão ligadas, ordenados por afinidade de conteúdo

O script NÃO decide nada e NÃO escreve nos seus arquivos. Ele prepara o
material; quem julga é o Claude, com você olhando (passo 7 do setup).

Uso:
    python3 mapear-contexto.py                 # usa ~/.claude/cerebro-path
    python3 mapear-contexto.py /caminho/cerebro
    python3 mapear-contexto.py --top 3         # nº de candidatos por nota (padrão 3)

Saída: ferramentas/relatorio-contexto.md  (dentro do cérebro)

Só usa a biblioteca padrão do Python 3 — nada para instalar, nada para pagar.
"""

import os
import re
import sys
import unicodedata
from collections import defaultdict
from datetime import date

# --- pastas que não são conhecimento -----------------------------------------
IGNORAR_DIRS = {".git", ".claude", "ferramentas", "node_modules", "kit-hermes", ".hermes"}
# `archive/` e `exemplos/` entram no inventário, mas nunca viram candidatos:
# são material aposentado ou modelo em branco.
NAO_SUGERIR = ("archive/", "exemplos/", "templates/", "inbox/")

CAMPOS_REL = ("relacionado", "relacionado_contexto", "relacionado_sugerido")

STOPWORDS = set("""
a as o os um uma uns umas de do da dos das em no na nos nas por para com sem sob sobre
e ou mas que se ao aos à às pelo pela pelos pelas este esta esse essa aquele aquela isso
isto aquilo eu ele ela nos vos eles elas meu minha seu sua nosso nossa lhe lhes já não
sim mais menos muito pouco todo toda todos todas outro outra ser estar ter haver fazer
foi era são está tem tinha fica ficou vai vou pode deve quando onde como porque qual
quais quem entao então também tambem só apenas ainda cada nesse nessa neste nesta the of
and to in for on is it be as at by from with
""".split())


# ============================================================ util
def sem_acento(txt):
    return "".join(c for c in unicodedata.normalize("NFD", txt)
                   if unicodedata.category(c) != "Mn")


def tokens(txt):
    """Palavras significativas: minúsculas, sem acento, sem palavra-cola, 4+ letras."""
    palavras = re.findall(r"[a-zà-ÿ0-9][a-zà-ÿ0-9\-]{3,}", sem_acento(txt.lower()))
    return {p for p in palavras if p not in STOPWORDS}


def ler(caminho):
    with open(caminho, "r", encoding="utf-8", errors="replace") as fh:
        return fh.read()


# ============================================================ frontmatter
def separar_frontmatter(texto):
    """Devolve (dict_do_cabecalho, corpo). Parser de YAML só do que usamos."""
    if not texto.startswith("---"):
        return {}, texto
    fim = texto.find("\n---", 3)
    if fim == -1:
        return {}, texto
    bloco, corpo = texto[3:fim], texto[fim + 4:]

    dados, chave_atual, lista_atual = {}, None, None
    for linha in bloco.splitlines():
        if not linha.strip() or linha.strip().startswith("#"):
            continue
        # item de lista simples:  - "caminho"
        m = re.match(r"^\s*-\s+(.*)$", linha)
        if m and chave_atual:
            item = m.group(1).strip().strip('"\'')
            # item de lista de objeto:  - alvo: "caminho"
            mo = re.match(r"^alvo:\s*(.*)$", item)
            if mo:
                item = mo.group(1).strip().strip('"\'')
            elif ":" in item and not item.startswith("http"):
                continue  # campo interno do objeto (elo:, por:, em:)
            if lista_atual is not None:
                lista_atual.append(item)
            continue
        # campos internos do objeto de relacionado_contexto
        if re.match(r"^\s{2,}(elo|por|em|forca|hash):", linha):
            continue
        m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", linha)
        if m:
            chave_atual, valor = m.group(1), m.group(2).strip()
            if valor == "":
                lista_atual = []
                dados[chave_atual] = lista_atual
            elif valor.startswith("["):
                dados[chave_atual] = [v.strip().strip('"\'')
                                      for v in valor.strip("[]").split(",") if v.strip()]
                lista_atual = None
            else:
                dados[chave_atual] = valor.strip('"\'')
                lista_atual = None
    return dados, corpo


# ============================================================ varredura
def varrer(raiz):
    notas = {}
    for pasta, subdirs, arquivos in os.walk(raiz):
        subdirs[:] = [d for d in subdirs if d not in IGNORAR_DIRS and not d.startswith(".")]
        for arq in arquivos:
            if not arq.endswith(".md"):
                continue
            caminho = os.path.join(pasta, arq)
            rel = os.path.relpath(caminho, raiz).replace(os.sep, "/")
            texto = ler(caminho)
            fm, corpo = separar_frontmatter(texto)
            notas[rel] = {
                "fm": fm,
                "corpo": corpo,
                "titulo": (re.search(r"^#\s+(.+)$", corpo, re.M).group(1).strip()
                           if re.search(r"^#\s+(.+)$", corpo, re.M) else os.path.basename(rel)[:-3]),
                "tokens": tokens(corpo),
                "tags": set(fm.get("tags", []) if isinstance(fm.get("tags"), list) else []),
                "area": fm.get("area", ""),
                "bytes": len(texto.encode("utf-8")),
            }
    return notas


def resolver(alvo, origem, notas, raiz):
    """Resolve um caminho citado (relativo ao arquivo ou à raiz) para uma chave de nota."""
    alvo = alvo.split("#")[0].strip()
    try:
        from urllib.parse import unquote
        alvo = unquote(alvo)
    except Exception:
        pass
    if not alvo or alvo.startswith(("http://", "https://", "mailto:")):
        return None
    if alvo in notas:
        return alvo
    juntado = os.path.normpath(os.path.join(os.path.dirname(origem), alvo)).replace(os.sep, "/")
    if juntado in notas:
        return juntado
    base = os.path.basename(alvo)
    iguais = [k for k in notas if os.path.basename(k) == base]
    return iguais[0] if len(iguais) == 1 else None


def arestas(notas, raiz):
    saida = defaultdict(set)      # origem -> destinos
    quebrados = []                # (origem, alvo, de_onde)
    por_campo = defaultdict(int)

    for rel, n in notas.items():
        for alvo in re.findall(r"\[[^\]]*\]\(([^)\s]+)\)", n["corpo"]):
            if not alvo.endswith(".md"):
                continue
            destino = resolver(alvo, rel, notas, raiz)
            if destino and destino != rel:
                saida[rel].add(destino); por_campo["corpo"] += 1
            elif not destino:
                quebrados.append((rel, alvo, "link no texto"))

        for campo in CAMPOS_REL:
            valores = n["fm"].get(campo) or []
            if isinstance(valores, str):
                valores = [valores]
            for alvo in valores:
                destino = resolver(alvo, rel, notas, raiz)
                if destino and destino != rel:
                    saida[rel].add(destino); por_campo[campo] += 1
                elif not destino:
                    quebrados.append((rel, alvo, campo))
    return saida, quebrados, por_campo


def afinidade(a, b):
    """Jaccard entre os vocabulários das duas notas, com peso extra para tag e área iguais."""
    ta, tb = a["tokens"], b["tokens"]
    if not ta or not tb:
        return 0.0
    inter = len(ta & tb)
    if inter == 0:
        return 0.0
    base = inter / len(ta | tb)
    if a["tags"] & b["tags"]:
        base += 0.10 * len(a["tags"] & b["tags"])
    if a["area"] and a["area"] == b["area"]:
        base += 0.05
    return base


# ============================================================ relatório
def main():
    argv = [a for a in sys.argv[1:]]
    top = 3
    if "--top" in argv:
        i = argv.index("--top")
        top = int(argv[i + 1]); del argv[i:i + 2]
    # --resumo: não escreve arquivo; imprime só o placar (usado pelo cron da VPS,
    # que entrega a saída no Telegram e fica em silêncio quando está tudo certo)
    resumo = "--resumo" in argv
    if resumo:
        argv.remove("--resumo")

    raiz = argv[0] if argv else ""
    if not raiz:
        marcador = os.path.expanduser("~/.claude/cerebro-path")
        if os.path.exists(marcador):
            raiz = ler(marcador).strip()
    raiz = os.path.abspath(os.path.expanduser(raiz or "."))
    if not os.path.isdir(raiz):
        print(f"✗ não achei o cérebro em {raiz}"); sys.exit(1)

    notas = varrer(raiz)
    if not notas:
        print(f"✗ nenhuma nota .md encontrada em {raiz}"); sys.exit(1)

    saida, quebrados, por_campo = arestas(notas, raiz)
    entrada = defaultdict(set)
    for o, ds in saida.items():
        for d in ds:
            entrada[d].add(o)

    grau = {r: len(saida.get(r, ())) + len(entrada.get(r, ())) for r in notas}
    orfas = sorted([r for r, g in grau.items() if g == 0
                    and not r.startswith(NAO_SUGERIR) and r != "MAPA.md"])
    ESTRUTURAIS = ("MAPA.md", "_index.md", "_registry.md", "CLAUDE.md",
                   "PADROES.md", "README.md", "AGENTS.md", "SOUL.md",
                   "USER.md", "MEMORY.md")
    sem_fm = sorted([r for r, n in notas.items() if not n["fm"]
                     and os.path.basename(r) not in ESTRUTURAIS
                     and not r.startswith(("archive/", "exemplos/", "templates/"))])

    # --- candidatos: pares fortes que ainda não estão ligados -----------------
    elegiveis = [r for r in notas if not r.startswith(NAO_SUGERIR)
                 and os.path.basename(r) not in ("MAPA.md", "_index.md", "_registry.md",
                                                 "CLAUDE.md", "PADROES.md", "README.md")]
    candidatos = []
    for i, a in enumerate(elegiveis):
        pontuados = []
        for b in elegiveis[i + 1:]:
            if b in saida.get(a, ()) or a in saida.get(b, ()):
                continue                      # já ligadas
            s = afinidade(notas[a], notas[b])
            if s > 0.08:
                pontuados.append((s, b))
        for s, b in sorted(pontuados, reverse=True)[:top]:
            candidatos.append((s, a, b))
    candidatos.sort(reverse=True)

    # --- modo resumo (cron): só o placar, e silêncio quando não há problema ----
    if resumo:
        pendentes = [r for r in notas if r.startswith("inbox/")
                     and os.path.basename(r) != "_index.md"]
        problemas = []
        if pendentes:
            problemas.append(f"{len(pendentes)} item(ns) esperando curadoria na inbox/")
        if quebrados:
            problemas.append(f"{len(quebrados)} link(s) quebrado(s)")
        if sem_fm:
            problemas.append(f"{len(sem_fm)} nota(s) sem cabeçalho")
        if orfas:
            problemas.append(f"{len(orfas)} nota(s) ilhada(s)")
        for arq, limite in (("MEMORY.md", 2200), ("USER.md", 1375)):
            caminho = os.path.join(raiz, arq)
            if os.path.exists(caminho):
                tam = os.path.getsize(caminho)
                if tam > limite * 0.9:
                    problemas.append(f"{arq} com {tam}B (limite {limite}B)")
        if problemas:
            print(f"🧹 Higiene do cérebro — {len(notas)} notas")
            for p in problemas:
                print(f"  · {p}")
            print("\nPara resolver: abra o Claude Code no computador e rode /ingestao.")
        return

    # --- escrita --------------------------------------------------------------
    destino_dir = os.path.join(raiz, "ferramentas")
    os.makedirs(destino_dir, exist_ok=True)
    destino = os.path.join(destino_dir, "relatorio-contexto.md")

    L = []
    add = L.append
    add(f"# Raio-X das ligações do cérebro — {date.today().isoformat()}\n")
    add("> Gerado por `mapear-contexto.py`. É material de trabalho para o Claude julgar,")
    add("> **não é verdade estabelecida**. Nenhum arquivo do cérebro foi alterado.\n")

    add("## 1. Inventário\n")
    add(f"- **{len(notas)}** notas em `{raiz}`")
    add(f"- **{sum(len(v) for v in saida.values())}** ligações existentes "
        f"(corpo: {por_campo['corpo']} · relacionado: {por_campo['relacionado']} · "
        f"contexto: {por_campo['relacionado_contexto']} · sugerido: {por_campo['relacionado_sugerido']})")
    vivos = [r for r in notas if not r.startswith(NAO_SUGERIR)]
    media = (sum(grau[r] for r in vivos) / len(vivos)) if vivos else 0
    add(f"- grau médio de ligação (fora de archive/inbox): **{media:.1f}**\n")

    add("### Notas por gaveta\n")
    por_gaveta = defaultdict(int)
    for r in notas:
        por_gaveta[r.split("/")[0] if "/" in r else "(raiz)"] += 1
    add("| Gaveta | Notas |")
    add("|---|---|")
    for g, q in sorted(por_gaveta.items(), key=lambda x: -x[1]):
        add(f"| `{g}` | {q} |")
    add("")

    add("## 2. Problemas a corrigir\n")
    add(f"### 2.1 Links quebrados ({len(quebrados)})\n")
    if quebrados:
        add("| Nota | Aponta para | Onde |")
        add("|---|---|---|")
        for o, alvo, onde in quebrados[:60]:
            add(f"| `{o}` | `{alvo}` | {onde} |")
        if len(quebrados) > 60:
            add(f"\n… e mais {len(quebrados) - 60}.")
    else:
        add("Nenhum. ✓")
    add("")

    add(f"### 2.2 Notas sem cabeçalho (frontmatter) — {len(sem_fm)}\n")
    if sem_fm:
        add("Estas não aparecem em busca por área, tipo ou tag:\n")
        for r in sem_fm[:60]:
            add(f"- `{r}`")
        if len(sem_fm) > 60:
            add(f"- … e mais {len(sem_fm) - 60}")
    else:
        add("Nenhuma. ✓")
    add("")

    add(f"### 2.3 Notas ilhadas — {len(orfas)}\n")
    if orfas:
        add("Ninguém aponta para elas e elas não apontam para ninguém. "
            "Existem, mas o cérebro não as encontra navegando:\n")
        for r in orfas[:60]:
            add(f"- `{r}` — {notas[r]['titulo']}")
        if len(orfas) > 60:
            add(f"- … e mais {len(orfas) - 60}")
    else:
        add("Nenhuma. ✓")
    add("")

    add(f"## 3. Ligações candidatas ({len(candidatos)})\n")
    add("Pares que falam de coisas parecidas e **ainda não estão ligados**. A afinidade é")
    add("lexical (vocabulário em comum) — é um **filtro barato**, não um julgamento.")
    add("O Claude lê cada par e decide se é o **mesmo objeto concreto** (mesmo fornecedor,")
    add("mesma loja, mesma pessoa, mesma decisão) ou só assunto parecido. Só o primeiro caso vira")
    add("`relacionado_contexto`, sempre com o **elo** escrito.\n")
    if candidatos:
        add("| # | Afinidade | Nota A | Nota B | Vocabulário em comum |")
        add("|---|---|---|---|---|")
        for i, (s, a, b) in enumerate(candidatos[:80], 1):
            comum = sorted(notas[a]["tokens"] & notas[b]["tokens"],
                           key=lambda t: -len(t))[:6]
            add(f"| {i} | {s:.2f} | `{a}` | `{b}` | {', '.join(comum)} |")
        if len(candidatos) > 80:
            add(f"\n… e mais {len(candidatos) - 80} pares abaixo do corte de exibição.")
    else:
        add("Nenhum par acima do corte. Cérebro pequeno ou já bem ligado.")
    add("")

    add("## 4. O que fazer com isto\n")
    add("Abra o Claude Code na pasta do cérebro e diga:\n")
    add("```")
    add("Leia ferramentas/relatorio-contexto.md e execute o prompt 05-prompt-relacionar.md")
    add("```\n")
    add("Ele vai propor as ligações com o elo explicado, você aprova, e só então elas são")
    add("gravadas no cabeçalho das notas.\n")
    add("---\n")
    add("*Quando este cérebro passar de ~150 notas, a comparação de vocabulário começa a*")
    add("*perder precisão e vale trocar o filtro por embeddings. O formato deste relatório*")
    add("*não muda — só a forma de ordenar os candidatos.*")

    with open(destino, "w", encoding="utf-8") as fh:
        fh.write("\n".join(L) + "\n")

    print(f"✓ Raio-X pronto: {destino}")
    print(f"  {len(notas)} notas · {sum(len(v) for v in saida.values())} ligações · "
          f"{len(quebrados)} links quebrados · {len(orfas)} ilhadas · "
          f"{len(candidatos)} candidatos")


if __name__ == "__main__":
    main()
