#!/usr/bin/env python3
"""Fase 1 do protocolo: inventario mecanico do projeto.

Extrai, sem julgamento:
  - controllers (caminho -> classe -> acoes publicas)
  - models (caminho -> classe -> tabela deduzida)
  - migrations (arquivo -> operacao -> tabela)
  - routes (arquivo -> quantidade de definicoes)
  - views Blade (por diretorio)
  - arquivos de teste (por area)
  - o REGISTRO DE MODULOS, detectando o modelo usado pelo projeto

Modelos de registro suportados:
  registry  - tabela/seed (consulta em projetos.json -> modulos.comando)
  pastas    - modulos como diretorios fisicos (modulos.caminho)
  implicito- sem registro: deriva de rotas + controllers
  hibrido   - os dois, com a intersecao como canonica

Saida:
  <dos>/_00_panorama/10-INVENTARIO-DE-MODULOS.md
  <dos>/inventario.json

Uso:
  python3 dossie_modules/_tools/inventario.py --projeto <id>
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict

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

RX_CLASSE = re.compile(r'^\s*(?:final\s+|abstract\s+)?class\s+(\w+)', re.M)
RX_METODO = re.compile(
    r'^\s*public\s+function\s+(\w+)\s*\(', re.M)
RX_ROTAS = re.compile(
    r'Route::(get|post|put|patch|delete|options|any|match|resource|apiResource)\s*\(')
RX_NOME_ROTA = re.compile(r"->name\(\s*['\"]([^'\"]+)['\"]")
RX_TABLE = re.compile(r"protected\s+\$table\s*=\s*['\"]([^'\"]+)['\"]")
RX_MIGRACAO = re.compile(r'^\s*(Schema::\w+\(|DB::statement\(\s*["\']'
                         r'(?:CREATE|DROP|ALTER)\s+(?:VIEW|TABLE)[^"\']*)', re.M | re.I)
RX_VIEW = re.compile(r'CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+([\w.]+)', re.I)
RX_TABLE_ALTER = re.compile(r"Schema::table\(\s*['\"]([^'\"]+)['\"]")
RX_TABLE_CREATE = re.compile(r"Schema::create\(\s*['\"]([^'\"]+)['\"]")
RX_ROUTE_DEF = re.compile(
    r"Route::(get|post|put|patch|delete|options|any|match)\(\s*"
    r"['\"]([^'\"]+)['\"]\s*,\s*['\"]?([\w\\\\:\-]*)")


def sh(cmd, cwd=None):
    return subprocess.run(cmd, shell=True, capture_output=True, text=True,
                          cwd=cwd).stdout


SKIP_DIRS = {
    'vendor', 'node_modules', '.git', '.svn', 'storage', 'bootstrap', 'public',
    'dist', 'build', '.idea', '.vscode', 'coverage', 'cache', 'tmp',
    'Console', 'Commands', 'Providers', 'Exceptions', 'Rules', 'Helpers',
    'Enums', 'Events', 'Listeners', 'Jobs', 'Mail', 'Notifications',
    'Policies', 'Observers', 'Middleware', 'Requests', 'Resources', 'Presenters',
    'Traits', 'Validators', 'ValueObjects', 'DTOs', 'Data', 'Casts',
    'Repositories', 'Services', 'Support', 'Utilities', 'helpers', 'pdfs',
    'assets_sucesu', 'stubs', 'specs', 'steps', 'ci', 'plan', 'reports',
    'wiki', 'ai-guides', 'reference',
}


def listar(base, padrao, raiz=None):
    """Arquivos que casam com o padrao, relativos a `raiz` (default: base)."""
    raiz = raiz or base
    out = []
    for dirpath, dirnames, filenames in os.walk(base):
        dirnames[:] = [d for d in dirnames
                       if d not in ('vendor', 'node_modules', '.git', 'storage',
                                    'bootstrap', 'public', 'dist', 'build')]
        for f in filenames:
            if f.endswith(padrao):
                out.append(os.path.relpath(os.path.join(dirpath, f), raiz))
    return sorted(out)


def _caminho_php(rel):
    """True se o arquivo .php parece conter uma classe de dominio."""
    return rel.endswith('.php') and not rel.startswith('database/')


def _classificar_controllers(raiz, rel, txt):
    cm = RX_CLASSE.search(txt)
    if not cm or 'Controller' not in cm.group(1):
        return None
    metodos = [m for m in RX_METODO.findall(txt)
               if m not in ('__construct', '__invoke', '__get', '__set',
                            '__call', 'middleware', 'getMiddleware')]
    return {'arquivo': rel, 'classe': cm.group(1), 'acoes': metodos}


def _classificar_models(raiz, rel, txt):
    cm = RX_CLASSE.search(txt)
    if not cm:
        return None
    parece_modelo = bool(re.search(
        r'extends\s+(?:Model|Eloquent|Authenticatable|Model_|Pivot)', txt))
    if not parece_modelo and 'Model' not in cm.group(1):
        return None
    if '/Models/' not in '/' + rel and '/Model/' not in '/' + rel \
            and not parece_modelo:
        return None
    tm = RX_TABLE.search(txt)
    tabela = tm.group(1) if tm else None
    if not tabela:
        snake = re.sub(r'(?<!^)(?=[A-Z])', '_', cm.group(1)).lower()
        tabela = snake + 's'
    return {'arquivo': rel, 'classe': cm.group(1), 'tabela': tabela,
            'tabela_explicita': bool(tm)}


def varrer(raiz):
    """Percorre a raiz do projeto e classifica os artefatos.

    Funciona em layouts padrao (`app/`, `resources/`, `database/`) e em
    layouts de modulo (`core/`, `modules/*/`, `V2/Modules/*/`, `services/*/`).
    """
    ctrl, mod, mig, views, test = [], [], [], [], []
    for dirpath, dirnames, filenames in os.walk(raiz):
        dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
        rel_dir = os.path.relpath(dirpath, raiz)
        if rel_dir == '.':
            rel_dir = ''
        for f in filenames:
            caminho = os.path.join(dirpath, f)
            rel = os.path.join(rel_dir, f) if rel_dir else f
            if f.endswith('.blade.php'):
                partes = rel.replace('\\', '/').split('/')
                chave = '/'.join(partes[:-1]) if len(partes) > 1 else '.'
                views.append({'diretorio': chave, 'arquivo': rel})
            elif f.endswith('.php'):
                if 'migrations' in rel.replace('\\', '/').lower():
                    m = _mig(caminho, rel)
                    if m:
                        mig.append(m)
                    continue
                if '/tests/' in '/' + rel.replace('\\', '/') or \
                        rel.startswith('tests/') or f.endswith('Test.php'):
                    test.append(rel)
                    continue
                if 'routes' in rel.replace('\\', '/'):
                    continue
                txt = open(caminho, encoding='utf-8', errors='ignore').read()
                if not _caminho_php(rel):
                    continue
                c = _classificar_controllers(raiz, rel, txt)
                if c:
                    ctrl.append(c)
                    continue
                m = _classificar_models(raiz, rel, txt)
                if m:
                    mod.append(m)
    return ctrl, mod, mig, views, test


def _mig(caminho, rel):
    txt = open(caminho, encoding='utf-8', errors='ignore').read()
    tab = op = None
    m = RX_TABLE_CREATE.search(txt)
    if m:
        tab, op = m.group(1), 'create'
    else:
        m = RX_TABLE_ALTER.search(txt)
        if m:
            tab, op = m.group(1), 'alter'
        else:
            mv = RX_VIEW.search(txt)
            if mv:
                tab, op = mv.group(1), 'view'
    if tab is None:
        return None
    nome = os.path.basename(rel)
    data = (nome[:4] + '-' + nome[4:6] + '-' + nome[6:8]
            if re.match(r'^\d{8}', nome) else '')
    return {'arquivo': rel, 'tabela': tab, 'operacao': op, 'data': data}


def controllers(raiz):
    c, _, _, _, _ = varrer(raiz)
    return c


def models(raiz):
    _, m, _, _, _ = varrer(raiz)
    return m


def migrations(raiz):
    _, _, mig, _, _ = varrer(raiz)
    return mig


def views(raiz):
    _, _, _, v, _ = varrer(raiz)
    cont = defaultdict(int)
    for x in v:
        cont[x['diretorio']] += 1
    return [{'diretorio': k, 'arquivos': n} for k, n in sorted(cont.items())]


def testes(raiz):
    _, _, _, _, t = varrer(raiz)
    cont = defaultdict(int)
    for rel in t:
        partes = rel.replace('\\', '/').split('/')
        chave = '/'.join(partes[:2]) if len(partes) > 1 else partes[0]
        cont[chave] += 1
    return [{'area': k, 'arquivos': n} for k, n in sorted(cont.items())]


def routes(raiz):
    pasta = os.path.join(raiz, 'routes')
    if not os.path.isdir(pasta):
        return []
    res = []
    for f in sorted(os.listdir(pasta)):
        if not f.endswith('.php'):
            continue
        txt = open(os.path.join(pasta, f), encoding='utf-8', errors='ignore').read()
        defs = RX_ROUTE_DEF.findall(txt)
        res.append({'arquivo': f'routes/{f}', 'definicoes': len(defs),
                    'nomes': sorted(set(RX_NOME_ROTA.findall(txt)))})
    return res


# ---------------------------------------------------------------- registro

def registro_por_tabela(cfg):
    """Modelo 'registry': le o registro pela consulta configurada.

    A ordem das colunas vem de `modulos.campos` (default: id, nome, slug, status).
    """
    mod = cfg['modulos']
    cmd = mod.get('comando')
    if not cmd:
        return None, 'sem comando configurado'
    raw = sh(cmd, cwd=cfg['raiz_workspace'])
    linhas = [l for l in raw.splitlines() if l.strip()]
    sep = mod.get('separador', '|')
    campos = mod.get('campos') or ['id', 'nome', 'slug', 'status']
    itens = []
    for l in linhas:
        if sep and sep in l:
            partes = l.split(sep)
        else:
            partes = [l]
        p = [x.strip() for x in partes]
        if len(p) < len(campos):
            continue
        reg = dict(zip(campos, p))
        itens.append({'slug': reg.get('slug') or reg.get('nome'),
                      'nome': reg.get('nome', ''),
                      'id': reg.get('id', ''),
                      'status': reg.get('status', ''),
                      'bruto': p})
    return itens, None


def registro_por_pastas(cfg):
    """Modelo 'pastas': modulos sao diretorios fisicos.

    `profundidade: 2` cobre o layout `Modules/<Grupo>/<Modulo>` (o slug vira
    `grupo/modulo`). A base e resolvida em `raiz_codigo`, que pode apontar
    para um projeto fora do workspace.
    """
    mod = cfg['modulos']
    caminho = mod.get('caminho')
    if not caminho:
        return None, 'sem caminho configurado'
    raiz_codigo = cfg.get('raiz_codigo') or cfg['raiz_workspace']
    base = os.path.join(raiz_codigo, caminho)
    if not os.path.isdir(base):
        return None, f'diretorio inexistente: {caminho}'
    ignorar = {i['slug'] if isinstance(i, dict) else i for i in (mod.get('ignorar') or [])}
    motivo = {i['slug']: i.get('razao', '') for i in (mod.get('ignorar') or [])
              if isinstance(i, dict)}
    profundidade = int(mod.get('profundidade') or 1)
    itens = []

    def contar(pasta):
        total = 0
        for _, _, files in os.walk(pasta):
            total += len([f for f in files if f.endswith('.php')])
        return total

    for d in sorted(os.listdir(base)):
        alvo = os.path.join(base, d)
        if not os.path.isdir(alvo) or d.lower() in ignorar:
            continue
        if profundidade >= 2:
            subs = [x for x in sorted(os.listdir(alvo))
                    if os.path.isdir(os.path.join(alvo, x))]
            for sub in subs:
                chave = f'{d}/{sub}'
                if sub.lower() in ignorar or chave.lower() in ignorar:
                    continue
                itens.append({'slug': chave.lower(), 'nome': chave,
                              'grupo': d, 'arquivos_php': contar(os.path.join(alvo, sub)),
                              'ignorado': False, 'razao': ''})
        else:
            itens.append({'slug': d.lower(), 'nome': d, 'arquivos_php': contar(alvo),
                          'ignorado': False, 'razao': ''})
    return itens, None


def registro_implicito(cfg):
    """Modelo 'implicito': deriva de rotas + controllers por prefixo."""
    itens = []
    for vnome, vraiz in cfg['raizes'].items():
        ctrl = controllers(vraiz)
        grupos = defaultdict(list)
        for c in ctrl:
            nome = c['classe']
            if nome.endswith('Controller'):
                nome = nome[:-len('Controller')]
            grupos[nome.lower()].append(c)
        for slug, cs in sorted(grupos.items()):
            itens.append({'slug': slug, 'nome': slug, 'versao': vnome,
                          'controllers': [c['arquivo'] for c in cs]})
    return itens, None


def detectar_e_registro(cfg):
    modelo = cfg['modulos'].get('modelo', 'implicito')
    if modelo == 'registry':
        itens, err = registro_por_tabela(cfg)
    elif modelo == 'pastas':
        itens, err = registro_por_pastas(cfg)
    else:
        itens, err = registro_implicito(cfg)
    if itens is None:
        # fallback: tenta o outro modelo antes de desistir
        alt = 'pastas' if modelo == 'registry' else 'registry'
        try:
            if alt == 'pastas':
                itens, err = registro_por_pastas(cfg)
            else:
                itens, err = registro_por_tabela(cfg)
        except Exception as e:            # noqa: BLE001
            itens, err = None, str(e)
    return modelo, itens or [], err


# ---------------------------------------------------------------- diff

def diff_versoes(inv):
    """Compara duas versoes: controllers, models, migrations."""
    vs = inv.get('versoes', {})
    if len(vs) < 2:
        return {}
    (n1, d1), (n2, d2) = list(vs.items())[:2]
    def conjunto(d, chave, campo):
        return {c[campo] for c in d.get(chave, [])}
    c1, c2 = conjunto(d1, 'controllers', 'arquivo'), conjunto(d2, 'controllers', 'arquivo')
    m1, m2 = conjunto(d1, 'models', 'arquivo'), conjunto(d2, 'models', 'arquivo')
    g1, g2 = conjunto(d1, 'migrations', 'tabela'), conjunto(d2, 'migrations', 'tabela')
    return {
        'versoes': [n1, n2],
        'controllers': {'so_' + n1: sorted(c1 - c2), 'so_' + n2: sorted(c2 - c1),
                        'em_ambas': len(c1 & c2)},
        'models': {'so_' + n1: sorted(m1 - m2), 'so_' + n2: sorted(m2 - m1),
                   'em_ambas': len(m1 & m2)},
        'tabelas': {'so_' + n1: sorted(g1 - g2), 'so_' + n2: sorted(g2 - g1),
                    'em_ambas': len(g1 & g2)},
    }


# ---------------------------------------------------------------- saida

def escrever_md(cfg, inv, out):
    m = inv['modulos_meta']
    d = inv.get('diff', {})
    L = []
    A = L.append
    A(f"# Inventário Mecânico — {cfg['titulo']}")
    A('')
    A(f"> **Gerado por:** `_tools/inventario.py` (Fase 1 do protocolo) · "
      f"**Projeto:** `{cfg['id']}`")
    A(f"> **Data:** {inv['gerado_em']} · **Modelo de registro detectado:** "
      f"`{m['modelo']}`")
    A('')
    A('> Documento **GERADO** — é reescrito a cada execução. Não edite aqui.')
    A('> A lista canônica de módulos (com classificação) está em '
      '`10-INVENTARIO-DE-MODULOS.md`; o critério de decisão, em '
      '`_protocolo/04-DECISAO-MODULOS.md`.')
    A('')
    A('---')
    A('')
    A('## 1. Volumetria')
    A('')
    A('| Versão | Controllers | Models | Migrations | Views (dir) | Testes (arquivos) |')
    A('|---|---|---|---|---|---|')
    for nome, v in inv.get('versoes', {}).items():
        A(f"| `{nome}` | {len(v.get('controllers', []))} | {len(v.get('models', []))} | "
          f"{len(v.get('migrations', []))} | {len(v.get('views', []))} | "
          f"{sum(t['arquivos'] for t in v.get('testes', []))} |")
    A('')
    A('## 2. Registro de módulos (bruto)')
    A('')
    A(f"Origem: **{m['origem']}**" + (f" — `{m['erro']}`" if m.get('erro') else ''))
    A('')
    A('| # | Slug | Nome | Evidência |')
    A('|---|---|---|---|')
    for i, it in enumerate(m['itens'], 1):
        ev = []
        if 'arquivos_php' in it:
            ev.append(f"{it['arquivos_php']} arquivos .php")
        if it.get('controllers'):
            ev.append(f"{len(it['controllers'])} controller(s)")
        if it.get('bruto'):
            ev.append(' | '.join(x for x in it['bruto'] if x))
        A(f"| {i} | `{it['slug']}` | {it.get('nome', '')} | {'; '.join(ev) or '—'} |")
    A('')
    A('## 3. Comparação entre versões')
    A('')
    if not d:
        A('_Projeto de versão única — sem comparativo._')
    else:
        n1, n2 = d['versoes']
        for chave, rotulo in (('controllers', 'Controllers'),
                              ('models', 'Models'),
                              ('tabelas', 'Tabelas')):
            A(f'### {rotulo}')
            A('')
            A(f"| Só em `{n1}` | Só em `{n2}` | Em ambas |")
            A('|---|---|---|')
            so1 = d[chave]['so_' + n1]
            so2 = d[chave]['so_' + n2]
            A(f"| {len(so1)} | {len(so2)} | {d[chave]['em_ambas']} |")
            for x in so1:
                A(f'| `{x}` | | |')
            for x in so2:
                A(f'| | `{x}` | |')
            A('')
    A('## 4. Rotas por arquivo')
    A('')
    A('| Versão | Arquivo | Definições | Nomes de rota |')
    A('|---|---|---|---|')
    for nome, v in inv.get('versoes', {}).items():
        for r in v.get('routes', []):
            A(f"| `{nome}` | `{r['arquivo']}` | {r['definicoes']} | "
              f"{len(r['nomes'])} nomeadas |")
    A('')
    A('## 5. Views por diretório')
    A('')
    A('| Versão | Diretório | Arquivos |')
    A('|---|---|---|')
    for nome, v in inv.get('versoes', {}).items():
        for x in v.get('views', []):
            A(f"| `{nome}` | `{x['diretorio']}` | {x['arquivos']} |")
    A('')
    A('## 6. Tabelas de banco (por migration)')
    A('')
    A('| Versão | Tabela | Operação | Migration |')
    A('|---|---|---|---|')
    for nome, v in inv.get('versoes', {}).items():
        for x in v.get('migrations', []):
            A(f"| `{nome}` | `{x['tabela']}` | {x['operacao']} | `{x['arquivo']}` |")
    A('')
    return '\n'.join(L) + '\n'


# ------------------------------------------------- lista canônica (curada)

RX_META = {
    'natureza': re.compile(r'\*\*Natureza:?\*\*:?\s*(.+)'),
    'estado': re.compile(r'\*\*Estado:?\*\*:?\s*(.+)'),
}
RX_BASE = re.compile(r'Base analisada:?\s*`?([\w./]+)`?')
RX_PUBLICO = re.compile(r'\b(portal público|público)\b', re.I)
RX_CMS = re.compile(r'\bCMS\b')


def meta_dossie(cfg, slug):
    """Le natureza/estado do 00-RESUMO.md do dossie, se existir.

    Fallback: quando o dossiê nao usa os campos padronizados, a natureza e
    inferida da presenca das palavras 'CMS' e 'público' no resumo — marcada
    como inferida, nunca como declarada.
    """
    p = os.path.join(cfg['dos_dir'], slug, '00-RESUMO.md')
    if not os.path.exists(p):
        return {}
    txt = open(p, encoding='utf-8', errors='ignore').read()
    out = {}
    for k, rx in RX_META.items():
        m = rx.search(txt)
        if m:
            out[k] = m.group(1).strip().split('—')[0].strip(' *_')
    if 'natureza' not in out:
        tem_cms = bool(RX_CMS.search(txt))
        tem_pub = bool(RX_PUBLICO.search(txt))
        if tem_cms and tem_pub:
            out['natureza'] = 'Ambos (CMS + Portal) _(inferido)_'
        elif tem_cms:
            out['natureza'] = 'CMS _(inferido)_'
        elif tem_pub:
            out['natureza'] = 'Portal _(inferido)_'
    if 'estado' not in out:
        m = RX_BASE.search(txt)
        if m:
            out['estado'] = f"base analisada: `{m.group(1)}` _(inferido)_"
    return out


def escrever_canonico(cfg, inv, out):
    """Lista canonica de modulos: registro + extras, com classificacao.

    Reproduzivel: tudo vem do registro e do `projetos.json`.
    """
    m = inv['modulos_meta']
    alias = cfg.get('alias') or {}
    extras = cfg.get('extras') or {}
    dos = cfg['dos_dir']
    estrangeiras = set()
    for _pid, _p in (_cfg.carregar_config().get('projetos') or {}).items():
        _d = os.path.abspath(os.path.join(cfg['raiz_workspace'], _p.get('dos', '')))
        if _d != os.path.abspath(dos):
            estrangeiras.add(_d)
    pastas = set(d for d in os.listdir(dos)
                 if os.path.isdir(os.path.join(dos, d))
                 and not d.startswith('_')
                 and os.path.abspath(os.path.join(dos, d)) not in estrangeiras)

    L = []
    A = L.append
    A(f"# Inventário Canônico de Módulos — {cfg['titulo']}")
    A('')
    A(f"> **Projeto:** `{cfg['id']}` · **Data:** {inv['gerado_em']} · "
      f"**Modelo de registro:** `{m['modelo']}`")
    A('')
    A('> Lista canônica: a fonte da verdade é o registro do sistema (ou a '
      'estrutura de pastas), completada pelos grupos extras declarados em '
      '`_protocolo/projetos.json`. O critério de decisão está em '
      '[`_protocolo/04-DECISAO-MODULOS.md`](../_protocolo/04-DECISAO-MODULOS.md).')
    A('')
    A('---')
    A('')
    A('## 1. Módulos do registro')
    A('')
    A('| # | Slug no registro | Slug do dossiê | Nome | Status | Natureza | Estado | Dossiê |')
    A('|---|---|---|---|---|---|---|---|')
    n = 0
    for it in m['itens']:
        slug_reg = it['slug']
        slug = alias.get(slug_reg, slug_reg)
        n += 1
        nome = it.get('nome', '')
        status = ''
        bruto = it.get('bruto') or []
        if len(bruto) >= 4:
            status = bruto[3]
        elif len(bruto) == 2:
            status = bruto[1]
        meta = meta_dossie(cfg, slug)
        tem = 'sim' if slug in pastas else '**NÃO**'
        nota = ''
        if slug_reg != slug:
            nota = f' *(alias de `{slug_reg}`)*'
        A(f"| {n} | `{slug_reg}` | `{slug}`{nota} | {nome} | {status or '—'} | "
          f"{meta.get('natureza', '—')} | {meta.get('estado', '—')} | {tem} |")
    A('')
    A(f"**Subtotal do registro: {n} módulos.**")
    A('')
    for chave, grupo in extras.items():
        A(f'## 2.{list(extras).index(chave) + 1} {grupo.get("rotulo", chave)}')
        A('')
        A(grupo.get('descricao', ''))
        A('')
        A('| Slug do dossiê | Descrição | Dossiê |')
        A('|---|---|---|')
        for slug, desc in (grupo.get('modulos') or {}).items():
            tem = 'sim' if slug in pastas else '**NÃO**'
            A(f'| `{slug}` | {desc} | {tem} |')
        A('')
        A(f"**Subtotal: {len(grupo.get('modulos') or {})} módulos.**")
        A('')
    sem_dossie = sorted(pastas - {alias.get(i['slug'], i['slug']) for i in m['itens']}
                        - {s for g in extras.values() for s in (g.get('modulos') or {})})
    A('## 3. Cobertura')
    A('')
    A('| Verificação | Resultado |')
    A('|---|---|')
    A(f"| Pastas de dossiê no total | {len(pastas)} |")
    A(f"| Módulos do registro | {n} |")
    A(f"| Módulos em grupos extras | "
      f"{sum(len(g.get('modulos') or {}) for g in extras.values())} |")
    A(f"| Pastas sem origem no registro nem nos extras | {len(sem_dossie)} |")
    if sem_dossie:
        A('')
        A('Pastas órfãs (decidir na F2 — teste C1): '
          + ', '.join(f'`{x}`' for x in sem_dossie))
    A('')
    return '\n'.join(L) + '\n', n, len(pastas)


def escrever_seguro(caminho, texto, forcar, gerados, proprios=()):
    """Escreve `texto` em `caminho`.

    Protecao: so sobrescreve arquivos que sao artefatos desta ferramenta
    (`proprios`) ou declarados como GERADOS no `projetos.json`. Um documento
    curado nunca e sobrescrito sem --forcar.
    """
    if os.path.exists(caminho):
        rel = os.path.relpath(caminho, _cfg.WORKSPACE)
        if rel not in gerados and rel not in proprios and not forcar:
            raise SystemExit(
                f"RECUSA: '{rel}' ja existe e nao e artefato desta ferramenta.\n"
                f"       Use --forcar para sobrescrever, ou corrija o caminho.")
    os.makedirs(os.path.dirname(caminho), exist_ok=True)
    with open(caminho, 'w', encoding='utf-8') as fh:
        fh.write(texto)
    return caminho


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    _cfg.cli_projeto(ap)
    ap.add_argument('--saida', default=None, help='caminho do .md mecanico de saida')
    ap.add_argument('--canonico', default=None, help='caminho do .md canonico de saida')
    ap.add_argument('--json', default=None, help='caminho do .json de saida')
    ap.add_argument('--forcar', action='store_true',
                    help='permite sobrescrever arquivo que nao e gerado')
    args = ap.parse_args()

    cfg = _cfg.load(args.projeto, args.raiz)
    raiz = cfg['raiz_workspace']

    inv = {'projeto': cfg['id'], 'titulo': cfg['titulo'],
           'gerado_em': __import__('datetime').date.today().isoformat(),
           'versoes': {}}

    for nome, vraiz in cfg['raizes'].items():
        if not os.path.isdir(vraiz):
            print(f'AVISO: versao {nome} nao existe em {vraiz}', file=sys.stderr)
            continue
        inv['versoes'][nome] = {
            'raiz': _cfg.caminho_relativo(vraiz, raiz),
            'controllers': controllers(vraiz),
            'models': models(vraiz),
            'migrations': migrations(vraiz),
            'routes': routes(vraiz),
            'views': views(vraiz),
            'testes': testes(vraiz),
        }

    modelo, itens, erro = detectar_e_registro(cfg)
    inv['modulos_meta'] = {'modelo': modelo, 'itens': itens, 'erro': erro,
                           'origem': {
                               'registry': 'consulta SQL / seed configurada',
                               'pastas': cfg['modulos'].get('caminho', ''),
                               'implicito': 'derivado de rotas + controllers',
                           }.get(modelo, '')}
    inv['diff'] = diff_versoes(inv)

    dos = cfg['dos_dir']
    panorama = os.path.join(dos, cfg.get('panorama', '_00_panorama'))
    os.makedirs(panorama, exist_ok=True)
    gerados = set(cfg.get('gerados') or [])
    out_md = args.saida or os.path.join(panorama, '11-INVENTARIO-MECANICO.md')
    out_can = args.canonico or os.path.join(panorama, '10-INVENTARIO-DE-MODULOS.md')
    out_json = args.json or os.path.join(dos, 'inventario.json')

    # Artefatos proprios: os arquivos que a tool escreve por padrao.
    # Um --saida/--canonico/--json apontando para outro lugar NAO e
    # artefato proprio: esse arquivo precisa estar declarado como GERADO
    # ou o gate de protecao exige --forcar.
    proprios = set()
    if not args.canonico:
        proprios.add(os.path.relpath(out_can, raiz))
    if not args.saida:
        proprios.add(os.path.relpath(out_md, raiz))
    if not args.json:
        proprios.add(os.path.relpath(out_json, raiz))
    # canônico primeiro: se a recusa de escrita abortar, o mecânico não é tocado
    txt_can, n_reg, n_pastas = escrever_canonico(cfg, inv, out_can)
    escrever_seguro(out_can, txt_can, args.forcar, gerados, proprios)
    txt_mec = escrever_md(cfg, inv, out_md)
    escrever_seguro(out_md, txt_mec, args.forcar, gerados, proprios)
    with open(out_json, 'w', encoding='utf-8') as fh:
        json.dump(inv, fh, ensure_ascii=False, indent=2)

    nv = len(inv['versoes'])
    print(f'inventario: {cfg["id"]} · modelo={modelo} · versoes={nv} · '
          f'modulos={len(itens)}')
    for nome, v in inv['versoes'].items():
        print(f'  {nome}: {len(v["controllers"])} controllers, '
              f'{len(v["models"])} models, {len(v["migrations"])} migrations, '
              f'{len(v["views"])} dirs de views')
    print(f'  canonico -> {out_can}  ({n_reg} no registro, {n_pastas} pastas)')
    print(f'  mecanico  -> {out_md}')
    print(f'  json      -> {out_json}')
    if erro:
        print(f'  AVISO: {erro}', file=sys.stderr)
    return 0


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