#!/usr/bin/env python3
"""Gate de qualidade dos dossiês (Fase 6 do protocolo).

Verifica:
  G1 Estrutura   - cada pasta de modulo tem os arquivos canonicos
  G2 Caminhos     - todo caminho de codigo citado existe no workspace
  G3 Tamanho      - nenhum arquivo fora da faixa de linhas
  G4 Placeholder  - nenhum marcador de texto por preencher
  G5 Corrupcao    - nenhum caractere CJK
  G6 Mapa versoes - comparativo entre versoes presente na pasta
  G7 Cobertura    - todo modulo do registro tem dossie

Configuracao em `_protocolo/projetos.json`.

Uso:
  python3 dossie_modules/_tools/auditar.py --projeto <id>
"""
import argparse
import glob as _g
import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _cfg  # noqa: E402

RX_CJK = re.compile(r'[一-鿿　-〿]')
RX_PH = re.compile(r'\b(?:TODO|FIXME|XXX)\b|<NOME>')
NEGACAO = re.compile(
    r'(N[ÃA]O\s+existe|n[ãa]o\s+existe|inexistente|não\s+possui|'
    r'n[ãa]o\s+tem\s+a\s+pasta|removido|ausente|\bfoi\s+removid|'
    r'\bfoi\s+excluíd|\bperdido|\bremoivid)', re.I)
RX_MAPA_PALAVRA = re.compile(
    r'(mapa|comparativo|regress[aã]o|diverg[eê]ncia|evolu[cç][aã]o|'
    r'correspond[eê]ncia|equival[eê]ncia)', re.I)
RX_HEADING_VERS = re.compile(r'^\s*#{1,4}\s.*\bv5\b.*\bv6\b|'
                              r'^\s*#{1,4}\s.*\bv6\b.*\bv5\b', re.I)

# Artefatos das ferramentas do protocolo, reconhecidos pelo nome.
NOMES_GERADOS = {
    '10-INVENTARIO-DE-MODULOS.md',   # lista canonica (inventario.py)
    '11-INVENTARIO-MECANICO.md',     # volumetria/diff (inventario.py)
    'README.md',                     # indice (gerar-readme.py)
}


def posicoes_fence(txt):
    """Intervalos (inicio, fim) dos blocos de codigo cercados por ```."""
    spans = []
    for m in re.finditer(r'^```', txt, re.M):
        spans.append(m.start())
    pares = []
    for i in range(0, len(spans) - 1, 2):
        fim = txt.find('\n', spans[i + 1])
        pares.append((spans[i], fim if fim > 0 else len(txt)))
    return pares


def dentro_de_fence(pos, spans):
    return any(a <= pos <= b for a, b in spans)


def build_rx_path(prefixos, extensoes):
    """Regex de caminho citado, com as extensoes mais longas primeiro.

    A ordem importa: o motor de regex resolve alternativas da esquerda para
    a direita, e `js` antes de `json` faria `foo.json` casar como `foo.js`
    (falso positivo). Por isso ordenamos por tamanho decrescente.
    """
    exts = sorted(set(extensoes), key=len, reverse=True)
    pref = '|'.join(re.escape(p) for p in sorted(prefixos, key=len, reverse=True))
    alt = '|'.join(exts)
    # O inicio do caminho precisa estar ancorado: sem isso, "cms/" casaria
    # dentro de "dossie_modules/cms/" e reportaria caminho inexistente.
    return re.compile(r'(?<![A-Za-z0-9_./-])(?:' + pref +
                      r')/[A-Za-z0-9_./<>-]+\.(?:' + alt + ')')


class Auditor:
    def __init__(self, cfg):
        self.cfg = cfg
        self.dos = cfg['dos_dir']
        self.raiz = cfg['raiz_workspace']
        self.raiz_codigo = cfg.get('raiz_codigo') or self.raiz
        self.canon = cfg['arquivos_canonicos']
        self.min_l = cfg.get('min_linhas', 100)
        self.max_l = cfg.get('max_linhas', 400)
        prefixos = list(cfg.get('prefixos') or [])
        # o proprio diretorio de dossie tambem e um prefixo valido
        base = os.path.basename(cfg['dos'].rstrip('/'))
        if base and base not in prefixos:
            prefixos.append(base)
        self.rx_path = build_rx_path(prefixos, cfg.get('extensoes') or [])
        self.problems = []
        self.excluir_tam = ('/_template/', '/90-LEGADO/')

    # ------------------------------------------------------------- utils
    def _estrangeiras(self):
        """Diretorios de dossie de OUTROS projetos mapeados."""
        out = set()
        try:
            for p in _cfg.carregar_config().get('projetos', {}).values():
                d = os.path.abspath(os.path.join(self.raiz, p.get('dos', '')))
                if d != os.path.abspath(self.dos):
                    out.add(d)
        except Exception:                                  # noqa: BLE001
            pass
        return out

    def all_md(self):
        """Documentos do PROJETO, nao do workspace inteiro.

        Escopo: arquivos .md na raiz do dossie + pastas de modulo (nivel 1 e
        um nivel abaixo, para subpastas como `90-LEGADO/`) + pastas
        auxiliares `_xxx`. Subpastas que pertencem a OUTRO projeto mapeado
        (ex.: `dossie_modules/cms/`) NAO entram.
        """
        out = []
        if not os.path.isdir(self.dos):
            return out
        estrangeiras = self._estrangeiras()
        for d in sorted(os.listdir(self.dos)):
            p = os.path.join(self.dos, d)
            if os.path.isfile(p):
                if p.endswith('.md'):
                    out.append(p)
                continue
            if not os.path.isdir(p):
                continue
            if os.path.abspath(p) in estrangeiras:
                continue
            out.extend(sorted(_g.glob(os.path.join(p, '*.md'))))
            for sub in sorted(_g.glob(os.path.join(p, '*'))):
                if os.path.isdir(sub) and os.path.abspath(sub) not in estrangeiras:
                    out.extend(sorted(_g.glob(os.path.join(sub, '*.md'))))
        return out

    def modulos(self):
        estrangeiras = self._estrangeiras()
        return [d for d in sorted(os.listdir(self.dos))
                if os.path.isdir(os.path.join(self.dos, d))
                and not d.startswith('_')
                and os.path.abspath(os.path.join(self.dos, d)) not in estrangeiras]

    # ------------------------------------------------------------- gates
    def g1_estrutura(self):
        for d in self.modulos():
            faltando = [f for f in self.canon
                        if not os.path.exists(os.path.join(self.dos, d, f))]
            if faltando:
                self.problems.append(f"[ESTRUTURA] {d}: falta {faltando}")
        return len(self.modulos())

    def g2_caminhos(self):
        vistos, faltando, esperados = set(), set(), set()
        for f in self.all_md():
            txt = open(f, encoding='utf-8', errors='ignore').read()
            for m in self.rx_path.finditer(txt):
                caminho = m.group(0)
                vistos.add(caminho)
                ini = txt.rfind('\n', 0, m.start()) + 1
                fim = txt.find('\n', m.end())
                linha = txt[ini:fim if fim > 0 else len(txt)]
                anterior = txt[:ini].rsplit('\n', 2)[-2] if ini else ''
                if NEGACAO.search(linha) or NEGACAO.search(anterior):
                    esperados.add(caminho)
        for p in sorted(vistos):
            if p in esperados:
                continue
            if os.path.exists(os.path.join(self.raiz_codigo, p)):
                continue
            if os.path.exists(os.path.join(self.raiz, p)):
                continue
            if '<' in p or '>' in p:
                continue          # notacao com parametro (<slug>, <id>)
            if '*' in p or '?' in p:
                if _g.glob(os.path.join(self.raiz_codigo, p)) or _g.glob(os.path.join(self.raiz, p)):
                    continue
            faltando.add(p)
        for p in sorted(faltando):
            self.problems.append(f"[CAMINHO] inexistente: {p}")
        return len(vistos), len(faltando)

    def g3_tamanho(self):
        """Faixa de linhas: so vale para dossie ENTREGUE.

        Em projeto `preparado` (ainda sem dossie preenchido) o gate de
        estrutura acusaria esqueleto como defeito — e esqueleto nao e
        entrega. A partir de `em_andamento`, a faixa passa a valer.
        """
        if self.cfg.get('estado', 'completo') == 'preparado':
            return
        gerados = {os.path.join(self.raiz, p) for p in (self.cfg.get('gerados') or [])}
        for f in self.all_md():
            if any(x in f for x in self.excluir_tam):
                continue
            if f in gerados or self._e_gerado_por_convencao(f):
                continue      # arquivo GERADO: o tamanho e' consequencia, nao defeito
            n = len(open(f, encoding='utf-8', errors='ignore').read().splitlines())
            if n < self.min_l or n > self.max_l:
                rel = os.path.relpath(f, self.raiz)
                self.problems.append(f"[TAMANHO] {n:4d} linhas: {rel}")

    def _e_gerado_por_convencao(self, f):
        """Artefatos das ferramentas, reconhecidos pelo nome e pela posicao."""
        nome = os.path.basename(f)
        if nome not in NOMES_GERADOS:
            return False
        pai = os.path.basename(os.path.dirname(f))
        return pai.startswith('_') or pai == os.path.basename(self.dos)

    def g4_placeholders(self):
        for f in self.all_md():
            if '/_template/' in f:
                continue
            txt = open(f, encoding='utf-8', errors='ignore').read()
            fences = posicoes_fence(txt)
            for m in RX_PH.finditer(txt):
                if txt[m.start() - 1:m.start()] == '`' and txt[m.end():m.end() + 1] == '`':
                    continue      # citacao literal de marcador no codigo legado
                if dentro_de_fence(m.start(), fences):
                    continue      # exemplo em bloco de codigo (limite conhecido do gate)
                linha = txt[:m.start()].count('\n') + 1
                ctx = txt[max(0, m.start() - 40):m.end() + 40].replace('\n', ' ')
                rel = os.path.relpath(f, self.raiz)
                self.problems.append(f"[PLACEHOLDER] {rel}:{linha} … {ctx} …")

    def g5_corrupcao(self):
        for f in self.all_md():
            s = open(f, encoding='utf-8', errors='ignore').read()
            c = RX_CJK.findall(s)
            if c:
                rel = os.path.relpath(f, self.raiz)
                self.problems.append(f"[CJK] {rel}: {sorted(set(c))}")

    def g6_mapa_versoes(self):
        """O mapa entre versoes pode estar em QUALQUER arquivo da pasta.

        Gate exige *existencia* da informacao, nao um arquivo especifico
        (licao 11 do protocolo). Aceita: titulo de secao com as duas versoes,
        ou linha que cite uma palavra-chave de comparacao e as duas versoes.

        So se aplica a projetos com DUAS versoes — em projeto de versao
        unica nao ha comparativo a exigir.
        """
        if len(self.cfg.get('raizes') or {}) < 2:
            return
        for d in self.modulos():
            pasta = os.path.join(self.dos, d)
            tem = False
            for f in sorted(_g.glob(os.path.join(pasta, '*.md'))):
                s = open(f, encoding='utf-8', errors='ignore').read()
                for h in s.splitlines():
                    if RX_HEADING_VERS.search(h):
                        tem = True
                        break
                    if RX_MAPA_PALAVRA.search(h) and 'v5' in h and 'v6' in h:
                        tem = True
                        break
                if tem:
                    break
            if not tem:
                self.problems.append(f"[MAPA v5->v6] ausente na pasta {d}/")

    def g8_cobertura_codigo(self):
        """Toda classe de dominio (controller/model) e' citada em algum dossie.

        Usa o `inventario.json` da F1. Prova por nome de classe: e' um sinal
        fraco, mas barato e suficiente para achar controller orfao.

        Uma lacuna **registrada** em `projetos.json` (`cobertura.lacunas`)
        nao reprova o gate: a regra do protocolo e' que toda lacuna tenha
        DESTINO (documentar, ou decidir que nao entra) — nao que toda
        lacuna ja esteja resolvida.
        """
        inv_path = os.path.join(self.dos, 'inventario.json')
        if not os.path.exists(inv_path):
            return 0, 0, 0
        if self.cfg.get('estado', 'completo') == 'preparado':
            return 0, 0, 0
        try:
            data = json.load(open(inv_path, encoding='utf-8'))
        except Exception:                                  # noqa: BLE001
            return 0, 0, 0
        texto = ''
        for f in self.all_md():
            texto += open(f, encoding='utf-8', errors='ignore').read()
        conhecidas = set((self.cfg.get('cobertura') or {}).get('lacunas') or [])
        total, faltando, registradas = 0, [], 0
        vistas = set()
        for v in data.get('versoes', {}).values():
            for item in v.get('controllers', []) + v.get('models', []):
                chave = item['arquivo']
                if chave in vistas:
                    continue
                vistas.add(chave)
                total += 1
                classe = item.get('classe', '')
                if len(classe) < 6 or classe in ('Controller', 'BaseController'):
                    continue
                if classe in texto:
                    continue
                if chave in conhecidas or classe in conhecidas:
                    registradas += 1
                    continue
                faltando.append(chave)
        for p in sorted(faltando):
            self.problems.append(f"[CODIGO] classe sem dossie: {p}")
        return total, len(faltando), registradas

    def g7_cobertura(self):
        """Todo modulo do registro tem dossie (quando o modelo e' registry).

        So se aplica quando o projeto esta `completo` — em fase de preparo
        o gate acusaria a ausencia de dossies que ainda nem foram pedidos.
        """
        if self.cfg.get('estado', 'completo') != 'completo':
            return 0, 0
        if self.cfg['modulos'].get('modelo') != 'registry':
            return 0, 0
        cmd = self.cfg['modulos'].get('comando')
        if not cmd:
            return 0, 0
        import subprocess
        raw = subprocess.run(cmd, shell=True, capture_output=True, text=True,
                             cwd=self.raiz).stdout
        sep = self.cfg['modulos'].get('separador', '|')
        campos = self.cfg['modulos'].get('campos') or ['id', 'nome', 'slug', 'status']
        alias = self.cfg.get('alias') or {}
        slugs = []
        for l in raw.splitlines():
            if not l.strip():
                continue
            partes = [x.strip() for x in (l.split(sep) if sep else [l])]
            if len(partes) < len(campos):
                continue
            reg = dict(zip(campos, partes))
            s = reg.get('slug') or reg.get('nome')
            if s:
                slugs.append(alias.get(s, s))
        pastas = set(self.modulos())
        faltando = [s for s in slugs if s not in pastas]
        for s in faltando:
            self.problems.append(f"[COBERTURA] modulo do registro sem dossie: {s}")
        return len(slugs), len(faltando)

    # ------------------------------------------------------------- main
    def run(self):
        nmod = self.g1_estrutura()
        npaths, nmissing = self.g2_caminhos()
        self.g3_tamanho()
        self.g4_placeholders()
        self.g5_corrupcao()
        self.g6_mapa_versoes()
        nreg, nfalta = self.g7_cobertura()
        ncod, ncodf, ncodr = self.g8_cobertura_codigo()

        arquivos = self.all_md()
        linhas = sum(len(open(f, encoding='utf-8', errors='ignore')
                          .read().splitlines()) for f in arquivos)
        print(f"projeto: {self.cfg['id']}")
        print(f"pastas de módulo : {nmod}")
        print(f"arquivos .md     : {len(arquivos)}")
        print(f"linhas totais    : {linhas}")
        print(f"caminhos citados : {npaths} (inexistentes: {nmissing})")
        if nreg:
            print(f"módulos registro : {nreg} (sem dossiê: {nfalta})")
        if ncod:
            print(f"classes de código: {ncod} (sem dossiê: {ncodf}, "
                  f"lacunas registradas: {ncodr})")
        if self.problems:
            print()
            for p in self.problems:
                print(p)
            print()
            print(f"FALHA — {len(self.problems)} problema(s).")
            return 1
        print()
        print("OK — nenhum problema encontrado.")
        return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    _cfg.cli_projeto(ap)
    args = ap.parse_args()
    cfg = _cfg.load(args.projeto, args.raiz)
    if not os.path.isdir(cfg['dos_dir']):
        _cfg.die(f"diretorio de dossie inexistente: {cfg['dos_dir']}")
    return Auditor(cfg).run()


if __name__ == '__main__':
    sys.exit(main())
