#!/usr/bin/env python3
"""
MEDIR - conte o tamanho do seu sistema E o mix de LINGUAGENS (sem preco).

Aberto de proposito: LEIA e rode voce mesmo.
Conta o CODIGO PROPRIO. Tudo que NAO e codigo proprio aparece na tabela de descontos,
com o numero na sua frente - nada e descontado em silencio.

**Nao calcula preco.** A medicao e transparente; o preco voce solicita ao consultor
com estes numeros. O mix de linguagens importa porque a REGUA da auditoria e DIFERENTE
por linguagem (PHP/JS != .NET != C).

Os descontos (o medidor reconhece TODOS sozinho - defaults no proprio codigo):
  1. DADO      - dump/carga de banco (.sql, .csv) - e dado, nao e codigo
  2. BIBLIOTECA- terceiro, por PASTA (node_modules, vendor, Libraries...) ou por nome
  3. GERADO    - produzido por ferramenta: *.d.ts, *_pb2.py... ou pasta de build (Objects/)
  4. DUPLICADO - copia byte-identica de outro arquivo ja contado
  5. COPIA/BACKUP - pasta com cara de versao antiga (old/, backup*/, AutoBackups/...)

ATENCAO sobre o desconto 5: pasta excluida NAO E AUDITADA. Excluimos por padrao
para nao cobrar por codigo morto, mas a lista e IMPRESSA na sua frente - se
alguma dessas pastas ainda estiver publicada, REINCLUA com '!nome/' no escopo.txt.

Uma UNICA lista editavel, 'escopo.txt', para o que o medidor nao adivinha.

Uso:
  (Windows use `python`; em macOS e Linux use `python3` — `python` nao existe la.)

  python medir.py [caminho]                    (padrao: diretorio atual)
  python medir.py [caminho] --manifesto m.json (emite o manifesto legivel por maquina)
  python medir.py [caminho] --detalhe          (lista o que caiu em cada desconto)
  python medir.py [caminho] --lang en          (saida em ingles; padrao: locale, senao pt)

Usage (English):
  python3 medir.py [path] --lang en        (macOS/Linux)
  ... same flags; the whole output is bilingual (PT/EN).
"""
import fnmatch
import hashlib
import json
import os
import re
import shutil
import sys
import time

# extensao -> linguagem (define o mix, que orienta a regua)
# 🔴 IDENTIFICACAO DO MEDIDOR — vai DENTRO do manifesto (2026-08-06).
# O manifesto e a PROVA da medicao: o site promete "preco fechado, saiu do
# manifesto que voce enviou, nao de estimativa". Sem dizer QUEM mediu, um
# manifesto gerado por um medir.py alterado (ou por uma versao antiga) e
# indistinguivel de um oficial — e ninguem tem como reproduzir o numero.
# Aconteceu de verdade em 2026-08-06: um manifesto chegou com o motivo
# "bundle minificado de terceiro (Lighthouse vendorizado)", string que NAO
# existe neste arquivo. Era uma copia local editada, de boa-fe, e so foi
# percebida porque alguem estranhou o texto.
# O sha256 e do PROPRIO arquivo em execucao: script alterado -> hash diferente.
MEDIDOR_VERSAO = "2026-08-24"   # triagem das 96 fichas do DEEP: A1 (nao destroi mais fonte do
                                # cliente) e G1 (`!arquivo` reinclui de verdade, em vez de excluir)


def _medidor_sha256():
    """sha256 deste arquivo. Devolve '' se nao der para ler (nunca quebra a
    medicao por causa da assinatura — o numero do cliente vale mais)."""
    try:
        with open(os.path.abspath(__file__), "rb") as fh:
            return hashlib.sha256(fh.read()).hexdigest()
    except Exception as ex:
        # 🔴 ACHADO 10 (auditoria ONE, 2026-08-23): devolver "" em silencio era fail-soft SEM
        # registro — e este campo e o que PROVA quem mediu. Manifesto sem assinatura fica
        # indistinguivel de um assinado para quem nao confere. Continua nao quebrando a medicao
        # (o numero do cliente vale mais), mas agora AVISA.
        sys.stderr.write("  [aviso] nao consegui assinar esta medicao (%s): o manifesto sai SEM "
                         "medidor_sha256, e a prova de quem mediu nao estara nele.\n" % ex)
        return ""


EXT_LANG = {
    ".php": "PHP", ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript",
    ".ts": "TypeScript", ".tsx": "TypeScript", ".vue": "Vue", ".py": "Python", ".rb": "Ruby",
    ".cs": "C#/.NET", ".go": "Go", ".java": "Java", ".kt": "Kotlin",
    ".c": "C", ".h": "C", ".cpp": "C++", ".cc": "C++", ".hpp": "C++",
    ".rs": "Rust", ".swift": "Swift", ".sql": "SQL",
    ".pas": "Delphi/Pascal", ".dpr": "Delphi/Pascal", ".dpk": "Delphi/Pascal",
    ".vb": "VB.NET",
    ".cob": "COBOL", ".cbl": "COBOL", ".cpy": "COBOL", ".cobol": "COBOL",
    ".b4a": "B4X", ".b4i": "B4X", ".b4j": "B4X", ".bas": "B4X (mobile)",
}

# 1. DADO - conteudo, nao codigo. Um dump de 4 milhoes de linhas nao e sistema.
EXT_DADO = {".sql", ".csv", ".tsv"}

# 3. GERADO - produzido por ferramenta. Auditar gerado e auditar o gerador.
GLOB_GERADO = [
    "*.xml.php", "*.d.ts", "*_pb2.py", "*_pb2_grpc.py", "*_pb.py", "*.pb.go",
    "*.g.dart", "*.freezed.dart", "*.generated.*", "*.designer.cs", "*.Designer.cs",
    "*.feature.cs", "*_generated.go",
]
# 2b. BIBLIOTECA por forma do nome (alem de LIB_FILES_DEFAULT, que e por NOME conhecido)
GLOB_LIB_FORMA = ["*.min.js", "*.min.css", "*.bundle.js", "*-bundle.js",
                  "*.lock", "*-lock.json"]   # "-bundle" achado no chrome-devtools-mcp

CONTATO = "SVO - contato@svo.com.br"

CAT_PROPRIO = "proprio"
CAT_DADO = "dado"
CAT_LIB = "biblioteca"
CAT_GERADO = "gerado"
CAT_DUP = "duplicado"
CAT_COPIA = "copia/backup"
CAT_PEDIDO = "excluido a seu pedido"
# 🔴 ACHADO 1 e 5 da auditoria ONE de 2026-08-23 (a primeira feita nas NOSSAS ferramentas).
# Antes, arquivo com extensao fora da EXT_LANG e arquivo ilegivel eram descartados com um `continue`
# mudo: nao contavam, nao descontavam, NAO APARECIAM. Um projeto so em linguagem nao coberta imprimia
# "BRUTO: 0 linhas em 0 arquivos" — indistinguivel de pasta vazia ou caminho errado. E o cliente com
# PHP + shell de deploy recebia um numero que omitia o shell inteiro, sem nenhum sinal.
# Isso e "o zero que mente" (regra do CLAUDE_GENERICO) dentro do proprio medidor, e contradizia o que
# este arquivo promete duas vezes: "nada e descontado em silencio".
CAT_DESCONHECIDO = "extensao nao reconhecida"
CAT_ILEGIVEL = "nao foi possivel ler"


# ====================================================================
# AS TRES LISTAS EDITAVEIS.
# Ficam AQUI como fonte unica e sao GRAVADAS em disco na primeira
# execucao. Assim o cliente baixa UM arquivo e mesmo assim recebe as
# listas para editar - e elas nunca divergem do codigo, porque saem
# daqui. Se o .txt existir, ele MANDA (o cliente editou).
# ====================================================================
# ====================================================================
# DEFAULTS DE EXCLUSAO — no CODIGO, nao em .txt (o cliente nao precisa ve-los
# nem edita-los). O medidor reconhece sozinho: framework/terceiro, lib solta,
# copia/backup por nome, gerado, duplicado, dado. O cliente edita UMA lista
# (escopo.txt) so para o que o medidor NAO adivinha. (Consolidacao 2026-08-05:
# antes eram 4 .txt — confuso para o cliente, regra 8.)
# ====================================================================
# Diretorios de framework/terceiro que nao contam (nome de pasta, em qualquer lugar).
LIB_DIRS_DEFAULT = {
    "node_modules", "vendor", "bower_components", "vcl", "PHPMailer",
    "third_party", "third-party", "3rdparty", "thirdparty",
    "dist", "build", "out", "obj", ".next", ".nuxt", "__pycache__",
    ".venv", "venv", ".git", "coverage", ".cache",
    # 🔴 'packages' SAIU desta lista em 2026-08-10. Casava em QUALQUER lugar e, num
    # monorepo JS/TS, 'packages/' e' o WORKSPACE PROPRIO (own code), nao terceiro.
    # Achado real (documenso, 2026-08-10): 1.656 arquivos de packages/ -- inclusive
    # packages/api/v1/implementation.ts (a API REST publica, 1.470 SLOC) -- sairam
    # como "framework/terceiro 'packages'". METADE do proprio sumiu da conta E da
    # auditoria, calado. Onde packages/ e' de terceiro de verdade ele esta' dentro de
    # node_modules/ (ja' excluido); um packages/ na RAIZ e' proprio. Mesma armadilha
    # do 'bin' abaixo. Principio (Humberto, 2026-08-10): melhor SOBRAR do que faltar
    # -- o cliente confia no nosso julgamento e NAO ve o que foi descontado; na
    # duvida, AUDITA. Vendorado raro: o cliente exclui no escopo.txt (decisao DELE,
    # e IMPRESSA no relatorio). Descontar proprio calado e' o erro que nao se paga.
    # 🔴 'bin' SAIU desta lista em 2026-08-06. Casava em QUALQUER lugar, e no Site
    # Kit (Google) tirou da conta bin/check-commit-msg.php e bin/check-node-version.js
    # -- scripts DO PROJETO -- com o motivo "framework/terceiro 'bin'". bin/ na raiz
    # e' entry point proprio; onde ele e' de terceiro (vendor/bin, node_modules/.bin)
    # a PASTA-MAE ja exclui. Mesma armadilha que o bloco do B4X abaixo documenta:
    # nome GENERICO excluido as cegas apaga codigo real. Binario nao entra de
    # qualquer forma -- so contam extensoes de codigo conhecidas.
    # CMS que o cliente NAO mantem (atualiza, nao corrige). Sem isto um WordPress
    # e cotado pelo codigo da Automattic (medido: 11.357 .php num WP6, quase tudo
    # core/plugin/tema). Continuam ENTRANDO: wp-config.php, tema custom,
    # mu-plugins/, .php em uploads/ (webshell), inventario de plugins (versao x CVE).
    "wp-admin", "wp-includes", "upgrade",
}
# ---- Defaults ESPECIFICOS de B4X, aplicados SO quando o projeto e B4X (tem um
# arquivo .b4a/.b4i/.b4j). Motivo: 'Objects' e 'Libraries' sao nomes GENERICOS -
# excluir cego em qualquer stack apagaria codigo real (noutro projeto 'Objects/'
# pode ser fonte). So a presenca do .b4a/.b4i garante que sao build/lib do B4X.
# Achado real (lockseed, 2026-08-05): 95% do SLOC eram 'Objects/gen/*.R.java'
# (build do compilador) contados como Java PROPRIO, e "Java" saiu como linguagem
# dominante no lugar de B4X - orcamento gigante e na linguagem errada.
B4X_BUILD_DIRS = {"Objects"}                     # saida do compilador -> GERADO
B4X_LIB_DIRS = {"Libraries"}                      # libs .jar/.xml de terceiro
B4X_COPIA_NOMES = ["autobackups", "autobackup*"]  # AutoBackups/ do IDE -> backup
# Libs soltas (arquivo unico jogado na arvore) por glob de nome.
# Cabecalho de copyright de TERCEIRO CONHECIDO — a quarta rede (2026-08-26).
# So entram detentores que NAO podem ser o cliente: fabricantes de runtime e de
# componente. Nada generico ("Copyright" sozinho nao basta): um arquivo do
# proprio cliente costuma ter copyright DELE, e classificar isso como biblioteca
# seria pior que o defeito que esta regra conserta.
# O casamento exige as DUAS coisas no cabecalho: marca de copyright E o nome do
# detentor. Ver a nota do minificado: cada rede cobre coisa diferente.
COPYRIGHT_TERCEIRO = [
    "borland", "embarcadero", "codegear",       # RTL/VCL do Delphi — o caso que escapou
    "project jedi",                             # JCL/JVCL, onipresentes em Delphi
    "tms software", "developer express", "devexpress", "raize software",
    "woll2woll", "steema software", "fastreport",
    "microsoft corporation",
    "oracle corporation", "sun microsystems",
]
# Quanto do inicio do arquivo se olha. Cabecalho de licenca vive nas primeiras
# linhas; ler mais so aumentaria o falso-positivo (um "copyright" citado no meio
# do codigo nao diz que o ARQUIVO e de terceiro).
COPYRIGHT_CABECALHO_BYTES = 4000


def _copyright_de_terceiro(bruto):
    """Devolve o nome do detentor, ou None. Nao levanta: erro -> None."""
    try:
        cab = bruto[:COPYRIGHT_CABECALHO_BYTES]
        if isinstance(cab, bytes):
            cab = cab.decode("utf-8", "replace")
        baixo = cab.lower()
        if "copyright" not in baixo and "(c)" not in baixo and "\u00a9" not in baixo:
            return None
        for dono in COPYRIGHT_TERCEIRO:
            if dono in baixo:
                return dono
    except Exception:
        return None
    return None


LIB_FILES_DEFAULT = [
    "*.min.js", "Chart*.js", "chart.js", "class.phpmailer.php", "PHPMailer*.php",
    "xajax*.js", "wz_*.js", "tip_*.js", "tooltip*.js", "calendar.js", "jquery*.js",
    "bootstrap*.js", "select2*.js", "moment*.js", "lodash*.js",
]
# Pastas com cara de copia/backup/versao antiga (glob de nome). Padrao e EXCLUIR
# (erramos a favor do bolso do cliente); se for codigo vivo, ele reinclui no ALVO.
COPIA_NOMES_DEFAULT = [
    "old", "old*", "*_old", "*-old", "antigo", "antigos", "antiga*",
    "backup", "backup*", "bkp", "bkp*", "*_backup", "*-backup",
    "naodeucerto", "lixo", "descartado*", "obsoleto*", "deprecated",
    "_old", "_bkp", "_backup", "_lixo",
]

# O texto-modelo do escopo.txt, nos DOIS idiomas. Gravado no idioma corrente
# (o --lang que a pagina de download ja traz no comando). Sem isto, a saida saia
# num idioma e o arquivo no outro. O cliente edita este UNICO arquivo.
ESCOPO_TXT = {
    "pt": """# O ESCOPO DA AUDITORIA — o que SAI e o que VOLTA. Vazio = auditar o projeto INTEIRO.
#
# Este arquivo faz DUAS coisas, e a segunda e a que quase ninguem descobre:
#
#   1. TIRAR o que voce nao quer auditar        ->  legacy/
#   2. TRAZER DE VOLTA o que o medidor tirou    ->  !minha_lib/
#
# O medidor desconta sozinho biblioteca, codigo gerado, duplicado, dado, backup
# (old/, backup*/) e saida de compilador (Objects/). Ele acerta quase sempre —
# mas quando ERRA e tira codigo SEU, e a linha com ! que traz de volta. Confira
# a lista "DESCONTADO" que aparece ao rodar: se tem algo seu ali, reinclua.
# 🔴 O que o medidor tira NAO e auditado. Codigo seu descontado por engano fica
# fora do relatorio sem ninguem perceber.
#
# UMA regra, simples: a BARRA no fim diz que e PASTA. Sem barra, e ARQUIVO.
#
#   saida_gerada/             -> TODA pasta com esse nome, em qualquer lugar
#   src/legacy/               -> SO essa pasta (caminho a partir da raiz do projeto)
#   *.min.js                  -> arquivos que casam (glob '*' e '?'), onde estiverem
#   scripts/importa_2019.php  -> um arquivo especifico
#   !backup_2024/             -> REINCLUI a PASTA (audita mesmo o que o medidor excluiria)
#   !bootstrap.js             -> REINCLUI o ARQUIVO (e codigo SEU, nao a biblioteca)
#
# RESUMO:  termina com  /  = pasta   |   sem  /  = arquivo (nome ou *.glob)
#          um  /  no meio = caminho especifico (so aquele)
#          sem  /  = nome em qualquer lugar da arvore   |   ! no inicio = reinclui
#
# 🔴 O QUE SAI DAQUI SAI DA AUDITORIA. Nao e so mais barato: nenhum problema
# dentro do que voce listar sera reportado. A lista e IMPRESSA ao rodar e vai no
# manifesto, para o relatorio declarar o que ficou de fora. Nada sai em silencio.
""",
    "en": """# THE AUDIT SCOPE — what goes OUT and what comes BACK. Empty = audit the WHOLE project.
#
# This file does TWO things, and almost nobody finds the second one:
#
#   1. TAKE OUT what you don't want audited    ->  legacy/
#   2. BRING BACK what the meter took out      ->  !my_lib/
#
# The meter discounts libraries, generated code, duplicates, data, backups and
# compiler output on its OWN. It is right almost always — but when it is WRONG
# and removes YOUR code, the line with ! brings it back. Check the "DISCOUNTED"
# list printed when you run it: if something of yours is there, bring it back.
# 🔴 What the meter removes is NOT audited. Your code discounted by mistake stays
# out of the report and nobody notices. Use this list only for what it does NOT guess.
#
# ONE simple rule: a trailing SLASH means FOLDER. No slash means FILE.
#
#   generated_out/            -> EVERY folder with this name, anywhere
#   src/legacy/               -> ONLY that folder (path from the project root)
#   *.min.js                  -> files that match (glob '*' and '?'), wherever they are
#   scripts/import_2019.php   -> one specific file
#   !backup_2024/             -> RE-INCLUDE the FOLDER (audit even what the meter would exclude)
#   !bootstrap.js             -> RE-INCLUDE the FILE (it is YOUR code, not the library)
#
# SUMMARY: ends with  /  = folder  |  no  /  = file (name or *.glob)
#          a  /  in the middle = specific path (that one only)
#          no  /  = name anywhere in the tree   |   ! at the start = re-include
#
# 🔴 WHAT LEAVES THIS LIST LEAVES THE AUDIT. It is not just cheaper: no problem
# inside what you list will be reported. The list is PRINTED when you run and goes
# into the manifest, so the report can state what was left out. Nothing leaves silently.
""",
}


def criar_listas(base):
    """Grava o escopo.txt se faltar, NO IDIOMA CORRENTE. Devolve os nomes
    criados (para avisar)."""
    caminho = os.path.join(base, "escopo.txt")
    if os.path.exists(caminho):
        return []
    try:
        with open(caminho, "w", encoding="utf-8", newline="\n") as fh:
            fh.write(ESCOPO_TXT["en" if IDIOMA == "en" else "pt"])
        return ["escopo.txt"]
    except OSError:
        return []          # pasta somente-leitura: seguimos com os defaults


def _load_list(base, fname, default):
    try:
        with open(os.path.join(base, fname), encoding="utf-8") as fh:
            return [l.strip() for l in fh if l.strip() and not l.startswith("#")]
    except FileNotFoundError:
        return default


def _match_any(nome, globs):
    return any(fnmatch.fnmatch(nome, p) for p in globs)


def _sob(rel, alvo):
    """rel esta DENTRO de alvo (ou e o proprio alvo)? Comparacao por caminho, nao por nome."""
    return rel == alvo or rel.startswith(alvo + "/")


def _eh_b4x(root):
    """Projeto B4X? So se tem arquivo-projeto .b4a/.b4i/.b4j na arvore. E o gate
    que autoriza excluir 'Objects/'/'Libraries/' (nomes genericos): sem o .b4a/.b4i
    eles podem ser codigo real de outro stack, e nao os tocamos."""
    for _dp, _dns, fns in os.walk(root):
        if any(f.lower().endswith((".b4a", ".b4i", ".b4j")) for f in fns):
            return True
    return False


def medir(root, ped, casou=None):
    """Percorre a arvore UMA vez e classifica cada arquivo em exatamente uma categoria.

    Nada e pulado em silencio: pasta de terceiro tambem e contada e aparece como
    desconto 'biblioteca'. So assim o cliente ve o que saiu da conta.

    `ped` e o que o cliente escreveu no escopo.txt, ja parseado por _parse_escopo:
    (dir_nomes, dir_cam, arq_cam, arq_globs, cruas). Tudo o mais (framework, copia,
    gerado, dado, duplicado) o medidor reconhece sozinho pelos defaults do codigo.
    `casou` (set opcional): recebe as entradas do escopo.txt que casaram algo — o
    chamador usa para avisar sobre linhas que nao pegaram nada (erro de digitacao).
    """
    (dir_nomes, dir_cam, arq_cam, arq_globs, reinc_nomes, reinc_cam,
     reinc_arq_cam, reinc_arq_globs, _cruas) = ped
    # Defaults B4X so entram se o projeto E B4X (tem .b4a/.b4i/.b4j) - senao
    # 'Objects'/'Libraries' poderiam ser codigo real de outro stack.
    b4x = _eh_b4x(root)
    lib_dirs = LIB_DIRS_DEFAULT | (B4X_LIB_DIRS if b4x else set())
    build_dirs = B4X_BUILD_DIRS if b4x else set()
    copia_nomes = COPIA_NOMES_DEFAULT + (B4X_COPIA_NOMES if b4x else [])
    copia_low = [c.lower() for c in copia_nomes]
    arquivos = []          # dicts: rel, categoria, motivo, sloc, lang, sha256
    vistos = {}            # sha256 -> primeiro caminho relativo com aquele conteudo

    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
        partes = [] if rel_dir == "." else rel_dir.split("/")

        # PEDIDO do cliente por PASTA (nome em qualquer lugar, ou caminho especifico)
        ped_nome = next((p for p in partes if p in dir_nomes), None)
        ped_camdir = next((c for c in dir_cam
                           if rel_dir == c or rel_dir.startswith(c + "/")), None)
        # REINCLUSAO do cliente (!): força auditar mesmo que um default excluiria.
        # Guardamos QUAL entrada casou para registrar em `casou` na forma CRUA
        # (com o '!'), senao a linha e sempre acusada de "nao casou com nada" —
        # o defeito irmao do G1, medido em 2026-08-24.
        reinc_hit = next((p for p in partes if p in reinc_nomes), None)
        if reinc_hit is None:
            reinc_hit = next((c for c in reinc_cam
                              if rel_dir == c or rel_dir.startswith(c + "/")), None)
        reincluido = reinc_hit is not None
        if reincluido and casou is not None:
            casou.add("!" + reinc_hit + "/")
        # DEFAULT: framework/terceiro e copia/backup por nome de pasta — a menos
        # que o cliente tenha reincluido explicitamente.
        lib_dir = None if reincluido else next(
            (p for p in partes if p in lib_dirs), None)
        build_dir = None if reincluido else next(
            (p for p in partes if p in build_dirs), None)
        copia_dir = None if reincluido else next(
            (p for p in partes if _match_any(p.lower(), copia_low)), None)
        prefixo_copia = ("/".join(partes[:partes.index(copia_dir) + 1])
                         if copia_dir else None)

        for fn in sorted(filenames):
            ext = os.path.splitext(fn)[1].lower()
            lang = EXT_LANG.get(ext)
            path = os.path.join(dirpath, fn)
            rel = os.path.relpath(path, root).replace(os.sep, "/")
            try:
                with open(path, "rb") as fh:
                    bruto = fh.read()
            except OSError as ex:
                # ILEGIVEL: entra como desconto declarado. Sumir em silencio era o defeito.
                arquivos.append({"rel": rel, "categoria": CAT_ILEGIVEL, "motivo": str(ex)[:80],
                                 "sloc": 0, "lang": None, "sha256": "", "pasta_copia": None})
                continue
            if not lang:
                # EXTENSAO NAO RECONHECIDA: conta as linhas e DECLARA como desconto, com o proprio
                # sufixo no motivo — assim o cliente ve o que ficou de fora e pode pedir a inclusao.
                sloc_d = sum(1 for l in bruto.decode("utf-8", "ignore").splitlines() if l.strip())
                arquivos.append({"rel": rel, "categoria": CAT_DESCONHECIDO,
                                 "motivo": ext or "(sem extensao)", "sloc": sloc_d,
                                 "lang": None, "sha256": hashlib.sha256(bruto).hexdigest(),
                                 "pasta_copia": None})
                continue
            sloc = sum(1 for l in bruto.decode("utf-8", "ignore").splitlines() if l.strip())
            sha = hashlib.sha256(bruto).hexdigest()

            # ORDEM IMPORTA: a primeira que casar e a categoria. O PEDIDO DO
            # CLIENTE VEM PRIMEIRO — se ele disse que nao quer, o motivo tem de ser
            # ESSE no relatorio, nao "parecia backup". Motivo errado faz o cliente
            # discutir a conta errada.
            arq_glob = next((g for g in arq_globs if fnmatch.fnmatch(fn, g)), None)
            # G1: reinclusao de ARQUIVO. Vem DEPOIS do pedido explicito de exclusao
            # (mesma ordem que ja vale para pasta) e ANTES de todos os defaults do
            # medidor — que e exatamente o que '!' significa: "isto e codigo meu,
            # nao aplique as suas regras aqui".
            reinc_arq = rel if rel in reinc_arq_cam else next(
                (g for g in reinc_arq_globs if fnmatch.fnmatch(fn, g)), None)
            if ped_nome:
                cat, motivo = CAT_PEDIDO, _("mot_pedido_pasta", ped_nome)
                if casou is not None: casou.add(ped_nome + "/")
            elif ped_camdir:
                cat, motivo = CAT_PEDIDO, _("mot_pedido_cam", ped_camdir)
                if casou is not None: casou.add(ped_camdir + "/")
            elif rel in arq_cam:
                cat, motivo = CAT_PEDIDO, _("mot_pedido_arq", rel)
                if casou is not None: casou.add(rel)
            elif arq_glob:
                cat, motivo = CAT_PEDIDO, _("mot_pedido_glob", arq_glob)
                if casou is not None: casou.add(arq_glob)
            elif reinc_arq:
                cat, motivo = CAT_PROPRIO, ""
                vistos.setdefault(sha, rel)
                if casou is not None: casou.add("!" + reinc_arq)
            elif copia_dir:
                cat, motivo = CAT_COPIA, _("mot_copia", copia_dir)
            elif build_dir:
                cat, motivo = CAT_GERADO, _("mot_build", build_dir)
            elif lib_dir:
                cat, motivo = CAT_LIB, _("mot_lib_dir", lib_dir)
            elif ext in EXT_DADO:
                cat, motivo = CAT_DADO, _("mot_dado", ext)
            elif _match_any(fn, LIB_FILES_DEFAULT) or _match_any(fn, GLOB_LIB_FORMA):
                cat, motivo = CAT_LIB, _("mot_lib_nome")
            # 🔴 MINIFICADO POR DENSIDADE — mede a coisa, nao o nome (2026-08-06).
            # Lista de nomes sempre perde a variante seguinte; densidade nao:
            # codigo escrito por gente tem linha curta, minificado tem linha de
            # milhares de bytes. Piso conservador (300 b/linha, min. 15 linhas),
            # medido contra os 3.525 arquivos proprios do Site Kit -> ZERO falsos.
            #
            # ⚠️ ISTO NAO PEGA BUNDLE "PRETTY", e e importante saber:
            # `lighthouse-devtools-mcp-bundle.js` (chrome-devtools-mcp) tem 57.899
            # linhas e apenas **62 bytes/linha** — e bundle de webpack SEM minify,
            # entao passa por esta regra. Quem o desconta e a pasta `third_party/`
            # (LIB_DIRS_DEFAULT) e o glob `*-bundle.js` logo abaixo. As tres regras
            # cobrem coisas DIFERENTES; nenhuma delas substitui as outras.
            elif sloc >= 15 and (len(bruto) / float(sloc)) > 300:
                cat = CAT_LIB
                motivo = _("mot_minificado", int(len(bruto) / float(sloc)))
            elif _copyright_de_terceiro(bruto):
                # 🔴 QUARTA REDE (2026-08-26): cabecalho de copyright de terceiro.
                # O `System.pas` da Borland (15.520 linhas) foi contado como PROPRIO
                # numa auditoria Delphi porque escapou das tres redes anteriores:
                # esta na RAIZ (nao ha pasta), tem nome generico (nao ha lista que o
                # pegue) e e Pascal de gente (a densidade nao acusa). O sinal estava
                # no cabecalho, que nenhuma das tres olhava.
                cat = CAT_LIB
                motivo = _("mot_lib_copyright", _copyright_de_terceiro(bruto))
            elif _match_any(fn, GLOB_GERADO):
                cat, motivo = CAT_GERADO, _("mot_gerado")
            elif sha in vistos:
                cat, motivo = CAT_DUP, _("mot_dup", vistos[sha])
            else:
                cat, motivo = CAT_PROPRIO, ""
                vistos[sha] = rel

            arquivos.append({
                "rel": rel, "categoria": cat, "motivo": motivo,
                "sloc": sloc, "lang": lang, "sha256": sha,
                "pasta_copia": prefixo_copia,
            })
    return arquivos


# ====================================================================
# FECHO - modo PARCIAL: medir so o que se liga a um arquivo-semente.
# Toda aresta que nao der para resolver e REGISTRADA, nunca adivinhada.
# ====================================================================
import re

RE_INC_DIR = re.compile(
    r"""\b(?:include|require)(?:_once)?\b\s*\(?\s*"""
    r"""(?:__DIR__|dirname\s*\(\s*__FILE__\s*\))\s*\.\s*(['"])([^'"]+)\1""", re.I)
RE_INC_LIT = re.compile(
    r"""\b(?:include|require)(?:_once)?\b\s*\(?\s*(['"])([^'"]+)\1""", re.I)
RE_INC_DIN = re.compile(
    r"""\b(?:include|require)(?:_once)?\b\s*\(?\s*[^'"\s)][^;]*""", re.I)
RE_URL_PHP = re.compile(r"""['"]([\w./\-]+\.php)(?:\?[^'"]*)?['"]""")


def _resolve(alvo, arq_rel, mapa):
    """Resolve um caminho citado para um arquivo REAL do projeto, ou None."""
    # "__DIR__ . '/x.php'" vem com barra inicial e e RELATIVO a pasta do arquivo.
    alvo = alvo.replace("\\", "/").lstrip("/")
    for base in (os.path.dirname(arq_rel), ""):
        c = os.path.normpath(os.path.join(base, alvo)).replace(os.sep, "/")
        if c.startswith("./"):
            c = c[2:]
        if c in mapa:
            return c
    return None


def arestas(root, arquivos):
    """(grafo, reverso, nao_resolvidas) sobre os arquivos PHP/JS do projeto."""
    mapa = {a["rel"]: a for a in arquivos}
    grafo, reverso, nao_res = {}, {}, []
    for a in arquivos:
        if not a["rel"].lower().endswith((".php", ".js")):
            continue
        try:
            with open(os.path.join(root, a["rel"]), encoding="utf-8",
                      errors="ignore") as fh:
                txt = fh.read()
        except OSError:
            continue
        destinos, citados = set(), set()
        for linha_n, linha in enumerate(txt.splitlines(), 1):
            for rx, tipo in ((RE_INC_DIR, "include __DIR__"), (RE_INC_LIT, "include literal")):
                for m in rx.finditer(linha):
                    citados.add(m.group(2))
                    d = _resolve(m.group(2), a["rel"], mapa)
                    if d:
                        destinos.add((d, tipo))
                    else:
                        nao_res.append({"arquivo": a["rel"], "linha": linha_n,
                                        "tipo": "alvo fora do projeto",
                                        "trecho": linha.strip()[:100]})
            if RE_INC_DIN.search(linha) and not RE_INC_DIR.search(linha) \
                    and not RE_INC_LIT.search(linha):
                nao_res.append({"arquivo": a["rel"], "linha": linha_n,
                                "tipo": "include dinamico (variavel)",
                                "trecho": linha.strip()[:100]})
            for m in RE_URL_PHP.finditer(linha):
                if m.group(1) in citados:
                    continue
                d = _resolve(m.group(1), a["rel"], mapa)
                if d and d != a["rel"]:
                    destinos.add((d, "url/chamada"))
        for d, tipo in destinos:
            grafo.setdefault(a["rel"], set()).add((d, tipo))
            reverso.setdefault(d, set()).add((a["rel"], tipo))
    return grafo, reverso, nao_res


def alcancaveis(sementes, grafo):
    vistos, fila = set(sementes), list(sementes)
    while fila:
        at = fila.pop()
        for d, _ig in grafo.get(at, ()):
            if d not in vistos:
                vistos.add(d)
                fila.append(d)
    return vistos


def totais(arquivos):
    t = {c: {"sloc": 0, "arquivos": 0} for c in
         (CAT_PROPRIO, CAT_DADO, CAT_LIB, CAT_GERADO, CAT_DUP, CAT_COPIA, CAT_PEDIDO,
          CAT_DESCONHECIDO, CAT_ILEGIVEL)}
    for a in arquivos:
        t[a["categoria"]]["sloc"] += a["sloc"]
        t[a["categoria"]]["arquivos"] += 1
    return t


def clusters_duplicados(arquivos):
    """Pares (pasta de origem, pasta da copia) -> quantos arquivos e quantas linhas.

    Serve para responder ao campo do ALVO 'pastas que sao COPIA/versao do mesmo codigo':
    a medicao DETECTA a copia; so voce sabe qual esta valendo.
    """
    por_sha = {}
    for a in arquivos:
        por_sha.setdefault(a["sha256"], []).append(a)
    pares = {}
    for grupo in por_sha.values():
        if len(grupo) < 2:
            continue
        orig = grupo[0]["rel"].rsplit("/", 1)[0] if "/" in grupo[0]["rel"] else "."
        for copia in grupo[1:]:
            dest = copia["rel"].rsplit("/", 1)[0] if "/" in copia["rel"] else "."
            k = (orig, dest)
            p = pares.setdefault(k, {"arquivos": 0, "sloc": 0})
            p["arquivos"] += 1
            p["sloc"] += copia["sloc"]
    return sorted(pares.items(), key=lambda kv: -kv[1]["sloc"])


# ----------------------------------------------------------------------
# IDIOMA. O site e bilingue e o cliente que roda isto pode ser de qualquer
# um dos dois lados - entao a saida tambem e. Todo texto vive aqui como par
# (PT, EN); nenhum texto solto no meio do codigo.
#
# Ordem de decisao:  --lang  >  SVO_LANG  >  locale do sistema  >  PT.
# Sem deteccao, o padrao e PT (mercado de origem) - e a linha que ensina a
# trocar aparece SEMPRE, nos dois idiomas, para quem caiu no lado errado.
# ----------------------------------------------------------------------
IDIOMA = "pt"


def _detecta_idioma():
    for v in (os.environ.get("SVO_LANG"), os.environ.get("LANGUAGE"),
              os.environ.get("LC_ALL"), os.environ.get("LANG")):
        if v:
            return "pt" if v.lower().startswith("pt") else "en"
    try:
        import locale
        import warnings
        # getdefaultlocale esta depreciada (sai no 3.15). Se sair, o except abaixo
        # pega e o padrao vale - o script NAO quebra por causa de idioma.
        # O `catch_warnings` e pelo CLIENTE: no 3.12+ ela imprime um DeprecationWarning
        # vermelho ANTES de qualquer saida nossa. Quem esta rodando isto pela primeira
        # vez le "warning" e para - e o nosso proprio texto manda parar quando algo
        # parece errado. Aviso de biblioteca nao pode ser a primeira coisa na tela.
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", DeprecationWarning)
            loc = locale.getdefaultlocale()[0] or ""
    except Exception:
        loc = ""
    if not loc:
        return "pt"
    return "pt" if loc.lower().startswith("pt") else "en"


TXT = {
    # --- listas editaveis
    "listas_criei":   ("\nCriei as listas editaveis nesta pasta (%s):",
                       "\nI created the editable lists in this folder (%s):"),
    "listas_mandam":  ("Sao ELAS que mandam a partir de agora. Abra, leia e edite se\n"
                       "alguma coisa da sua arvore estiver na categoria errada.",
                       "THEY are what counts from now on. Open them, read them and edit\n"
                       "if anything in your tree is in the wrong category."),

    # --- erros
    "err_semente_fora": ("ERRO: a semente %s nao esta dentro da raiz %s",
                         "ERROR: the seed %s is not inside the root %s"),
    "err_semente_med":  ("ERRO: a semente ficou fora da medicao (extensao nao contada,\n"
                         "      ou dentro de pasta excluida). Semente: %s",
                         "ERROR: the seed fell outside the measurement (extension not\n"
                         "       counted, or inside an excluded folder). Seed: %s"),
    "lang_adivinhado":  ("(saida em portugues - deduzido deste computador; para ingles: --lang en)",
                         "(output in English - guessed from this computer; para portugues: --lang pt)"),
    "err_path":         ("ERRO: --path exige o caminho da pasta do projeto.",
                         "ERROR: --path needs the path to the project folder."),
    "err_manifesto":    ("ERRO: --manifesto exige o caminho do arquivo de saida.",
                         "ERROR: --manifesto requires the output file path."),
    "err_raiz":         ("ERRO: --raiz exige o caminho da raiz do projeto.",
                         "ERROR: --raiz requires the project root path."),
    "err_lang":         ("ERRO: --lang exige pt ou en.",
                         "ERROR: --lang requires pt or en."),
    "err_caminho":      ("ERRO: caminho nao encontrado: %s",
                         "ERROR: path not found: %s"),
    # 🔴 ACHADO A1 (triagem de 2026-08-24, MEDIDO): `--manifesto --lang en app/index.php`
    # fazia o `--manifesto` engolir o `--lang`, sobrar `app/index.php` como VALOR do
    # manifesto, e o JSON ser gravado POR CIMA do codigo do cliente, com exit 0.
    "err_flag_valor":   ("ERRO: %s espera um caminho, e veio outra opcao: %s\n"
                         "      Coloque o caminho logo depois de %s.",
                         "ERROR: %s expects a path, but another option came: %s\n"
                         "       Put the path right after %s."),
    "err_manif_codigo": ("ERRO: recuso gravar o manifesto por cima de %s.\n"
                         "      Esse arquivo tem extensao de CODIGO (%s) - gravar ali\n"
                         "      destruiria o seu fonte. Escolha outro nome de saida.",
                         "ERROR: refusing to write the manifest over %s.\n"
                         "       That file has a CODE extension (%s) - writing there\n"
                         "       would destroy your source. Choose another output name."),
    "err_manif_escrita": ("ERRO: nao consegui gravar o manifesto em %s (%s).\n"
                          "      A medicao acima esta correta, mas NAO ha prova gravada.",
                          "ERROR: could not write the manifest to %s (%s).\n"
                          "       The measurement above is correct, but there is NO written proof."),

    # --- cabecalho
    "cab":              ("\n=== Medicao do sistema ===",
                         "\n=== System measurement ==="),
    "modo_parcial":     ("MODO PARCIAL - semente: %s",
                         "PARTIAL MODE - seed: %s"),
    "modo_completo":    ("MODO COMPLETO - pasta: %s",
                         "FULL MODE - folder: %s"),
    "raiz":             ("raiz: %s   (%s)",
                         "root: %s   (%s)"),
    "raiz_marcador":    ("marcador '%s'",
                         "marker '%s'"),
    "raiz_sem_marcador": ("sem marcador de projeto - usei a pasta do arquivo",
                          "no project marker - used the file's own folder"),
    "raiz_informada":   ("informada com --raiz",
                         "given with --raiz"),
    "raiz_aviso":       ("\n!!! ATENCAO: nao achei .git/composer.json/package.json subindo a arvore,\n"
                         "!!! entao usei a PASTA DO ARQUIVO como raiz. Se o projeto for maior que\n"
                         "!!! isso, o recorte esta truncado e os numeros estao errados.\n"
                         "!!! Rode de novo com:  --raiz <pasta-raiz-do-projeto>",
                         "\n!!! WARNING: no .git/composer.json/package.json found up the tree, so\n"
                         "!!! I used THE FILE'S OWN FOLDER as the root. If the project is bigger\n"
                         "!!! than that, this slice is truncated and the numbers are wrong.\n"
                         "!!! Run again with:  --raiz <project-root-folder>"),

    # --- modo parcial
    "recorte":          ("\nRECORTE (o que sera auditado):",
                         "\nSLICE (what will be audited):"),
    "recorte_num":      ("   %s arquivos proprios | %s linhas | %.1f%% do sistema",
                         "   %s own files | %s lines | %.1f%% of the system"),
    "recorte_def":      ("   = quem usa a semente + o que a semente usa + o que esses usam",
                         "   = who uses the seed + what the seed uses + what those use"),
    "repo_inteiro":     ("\nREPOSITORIO INTEIRO: %s linhas proprias em %s arquivos",
                         "\nWHOLE REPOSITORY: %s own lines in %s files"),
    "repo_porque":      ("^ este numero existe de proposito: sem ele, um recorte pequeno e\n"
                         "  indistinguivel de um sistema pequeno.",
                         "^ this number is here on purpose: without it, a small slice is\n"
                         "  indistinguishable from a small system."),
    "semente_base":     ("ESTA SEMENTE NAO RECORTA NADA - ela cobre %.0f%% do sistema.",
                         "THIS SEED SLICES NOTHING - it covers %.0f%% of the system."),
    "semente_base_txt": ("Sintoma de semente de BASE (classe de banco, config, autenticacao):\n"
                         "todo mundo usa, entao 'quem usa' e o sistema inteiro.\n"
                         "Se voce quer um MODULO, escolha uma semente de FLUXO - o arquivo\n"
                         "que faz a coisa (emitir boleto, fechar comissao), nao a peca que\n"
                         "todos usam. Se voce QUER o sistema inteiro, use o modo completo:\n"
                         "   python medir.py <pasta-do-projeto>",
                         "Symptom of a BASE seed (database class, config, authentication):\n"
                         "everybody uses it, so 'who uses it' is the whole system.\n"
                         "If you want a MODULE, pick a FLOW seed - the file that does the\n"
                         "thing (issue an invoice, close a commission), not the part that\n"
                         "everyone uses. If you DO want the whole system, use full mode:\n"
                         "   python medir.py <project-folder>"),
    "quem_usa":         ("\n[1] QUEM USA A SEMENTE (%s, subindo o grafo):",
                         "\n[1] WHO USES THE SEED (%s, walking up the graph):"),
    "o_que_usa":        ("\n[2] O QUE A SEMENTE USA (%s):",
                         "\n[2] WHAT THE SEED USES (%s):"),
    "usam_tambem":      ("\n[3] O QUE OS DE [1] USAM TAMBEM (%s):",
                         "\n[3] WHAT THE ONES IN [1] ALSO USE (%s):"),
    "seq_titulo":       ("SEQUENCIAS DE FUNCIONAMENTO QUE PASSAM PELA SEMENTE (%s)",
                         "WORKING SEQUENCES THAT GO THROUGH THE SEED (%s)"),
    "seq_txt":          ("Cada sequencia comeca num arquivo que ninguem chama - uma ENTRADA\n"
                         "de verdade (tela, endpoint, cron) - e vai ate a semente.",
                         "Each sequence starts at a file nobody calls - a real ENTRY POINT\n"
                         "(screen, endpoint, cron job) - and runs down to the seed."),
    "seq_continua":     ("\n   ... e a partir da semente o fluxo continua para:",
                         "\n   ... and from the seed the flow continues to:"),
    "xref":             ("\nREFERENCIA CRUZADA (somente entre os arquivos do recorte):",
                         "\nCROSS-REFERENCE (only among the files in the slice):"),
    "xref_semente":     ("  <== SEMENTE",
                         "  <== SEED"),
    "xref_usa":         ("      -> usa (%s): %s",
                         "      -> uses (%s): %s"),
    "xref_usado":       ("      <- usado por (%s): %s",
                         "      <- used by (%s): %s"),
    "fora_recorte":     ("\nFORA DO RECORTE (%s arquivos proprios) - NAO SERAO AUDITADOS:",
                         "\nOUTSIDE THE SLICE (%s own files) - WILL NOT BE AUDITED:"),
    "e_mais":           ("   ... e mais %s",
                         "   ... and %s more"),
    "fronteiras":       ("\nFRONTEIRAS - arestas que NAO consegui resolver (%s):",
                         "\nBOUNDARIES - edges I could NOT resolve (%s):"),
    "nenhuma":          ("   nenhuma.",
                         "   none."),
    "fronteiras_txt":   ("\n^ cada fronteira e um lugar onde a analise estatica PARA. Se alguma\n"
                         "  delas apontar para dentro do modulo, o recorte esta incompleto -\n"
                         "  por isso ela e listada em vez de ignorada.",
                         "\n^ every boundary is a place where static analysis STOPS. If any of\n"
                         "  them points into the module, the slice is incomplete - which is why\n"
                         "  it is listed instead of ignored."),
    "confira":          ("\nConfira as listas acima antes de fechar o orcamento:\n"
                         "o que voce autorizar e o que sera auditado - o resto nao e olhado.",
                         "\nCheck the lists above before closing the quote:\n"
                         "what you authorise is what gets audited - the rest is not looked at."),

    # --- modo completo
    "bruto":            ("\nBRUTO (tudo que tem extensao de codigo): %s linhas em %s arquivos",
                         "\nRAW (everything with a code extension): %s lines in %s files"),
    "descontos":        ("\nDESCONTOS (o que NAO e codigo proprio seu):",
                         "\nDISCOUNTS (what is NOT your own code):"),
    # 🔴 MOTIVOS — traduzidos desde 2026-08-06. Eram strings fixas em portugues
    # DENTRO do manifesto, mesmo com --lang en: o cliente internacional abria a
    # prova da propria medicao e lia "framework/terceiro"; e o auditor, que
    # escreve o relatorio em ingles, lia o ESCOPO em portugues. (Humberto viu o
    # efeito numa execucao: manifesto num idioma, saida no outro.)
    # 🔴 As duas categorias que faltavam ate 2026-08-23 — sem elas, arquivo de extensao nao
    # coberta e arquivo ilegivel sumiam sem aparecer em lugar nenhum, e a frase "o resto esta
    # descontado acima, item por item" ficava falsa.
    "cat_desconhecido":  ("ext. nao coberta", "unlisted ext."),
    "cat_ilegivel":      ("nao pude ler", "unreadable"),
    "mot_pedido_pasta":  ("pasta '%s/' em escopo.txt", "folder '%s/' in escopo.txt"),
    "mot_pedido_cam":    ("pasta '%s/' em escopo.txt", "folder '%s/' in escopo.txt"),
    "mot_pedido_arq":    ("arquivo '%s' em escopo.txt", "file '%s' in escopo.txt"),
    "mot_pedido_glob":   ("padrao '%s' em escopo.txt", "pattern '%s' in escopo.txt"),
    "mot_copia":         ("pasta '%s' parece copia/backup", "folder '%s' looks like a copy/backup"),
    "mot_build":         ("saida de build/compilador '%s/'", "build/compiler output '%s/'"),
    "mot_lib_dir":       ("framework/terceiro '%s'", "framework/third-party '%s'"),
    "mot_dado":          ("extensao %s", "extension %s"),
    "mot_lib_nome":      ("lib solta (forma do nome)", "standalone lib (name shape)"),
    "mot_minificado":    ("minificado/bundle (%d bytes por linha)", "minified/bundle (%d bytes per line)"),
    "mot_gerado":        ("padrao de arquivo gerado", "generated-file pattern"),
    "mot_lib_copyright": ("copyright de terceiro no cabecalho (%s)",
                          "third-party copyright in header (%s)"),
    "mot_dup":           ("identico a %s", "identical to %s"),
    "col_linhas":       ("linhas",
                         "lines"),
    "col_arquivos":     ("arquivos",
                         "files"),
    "cat_dado":         ("dado", "data"),
    "cat_lib":          ("biblioteca", "library"),
    "cat_gerado":       ("gerado", "generated"),
    "cat_dup":          ("duplicado", "duplicate"),
    "cat_copia":        ("copia/backup", "copy/backup"),
    "cat_pedido":       ("seu pedido", "your request"),
    "pedido_titulo":    ("VOCE PEDIU PARA EXCLUIR ESTES CAMINHOS - CONFIRA",
                         "YOU ASKED TO EXCLUDE THESE PATHS - CHECK THEM"),
    "pedido_txt":       ("Voce escreveu estes caminhos em 'escopo.txt'. Eles ficaram FORA da conta\n"
                         "E FICARAO FORA DA AUDITORIA: nenhum problema encontrado dentro deles sera\n"
                         "reportado. Isto vai no manifesto, para o relatorio poder declarar o que\n"
                         "ficou de fora - mas quem decidiu foi voce, e so voce pode desfazer.",
                         "You wrote these paths in 'escopo.txt'. They are OUT of the count AND\n"
                         "WILL BE OUT OF THE AUDIT: no problem found inside them will be reported.\n"
                         "This goes into the manifest so the report can declare what was left out -\n"
                         "but you decided it, and only you can undo it."),
    "pedido_desfaz":    ("Mudou de ideia? Apague a linha em 'escopo.txt' e rode de novo.",
                         "Changed your mind? Delete the line in 'escopo.txt' and run again."),
    "pedido_nada":      ("AVISO: 'escopo.txt' tem %s caminho(s) que nao casaram com nada na arvore.\n"
                         "Confira se escreveu certo - caminho e RELATIVO A RAIZ DO PROJETO:",
                         "WARNING: 'escopo.txt' has %s path(s) that matched nothing in the tree.\n"
                         "Check the spelling - the path is RELATIVE TO THE PROJECT ROOT:"),
    "proprio":          ("\nCODIGO PROPRIO: %s linhas em %s arquivos",
                         "\nOWN CODE: %s lines in %s files"),
    "proprio_pct":      ("  (%.1f%% do bruto - o resto esta descontado acima, item por item)",
                         "  (%.1f%% of raw - the rest is discounted above, item by item)"),
    "copias_titulo":    ("PASTAS EXCLUIDAS POR PARECEREM COPIA/BACKUP - CONFIRA UMA A UMA",
                         "FOLDERS EXCLUDED FOR LOOKING LIKE COPY/BACKUP - CHECK ONE BY ONE"),
    "copias_txt":       ("Estas pastas ficaram FORA da conta E FICARAO FORA DA AUDITORIA.\n"
                         "Nenhum problema encontrado dentro delas sera reportado.\n"
                         "Excluimos por padrao para o orcamento nao cobrar por codigo morto.",
                         "These folders are OUT of the count AND WILL BE OUT OF THE AUDIT.\n"
                         "No problem found inside them will be reported.\n"
                         "We exclude by default so the quote does not charge for dead code."),
    "copias_pergunta":  ("Alguma delas AINDA ESTA PUBLICADA ou ainda e usada?\n"
                         "=> escreva  !nome-da-pasta/  no escopo.txt (o ! REINCLUI) e rode de novo.\n"
                         "   (a pasta antiga que continua no ar e alvo de verdade: num caso real\n"
                         "    ela tinha MENOS protecao que a producao, e era por ela que se entrava)",
                         "Is any of them STILL PUBLISHED or still in use?\n"
                         "=> write  !folder-name/  in escopo.txt (the ! RE-INCLUDES) and run again.\n"
                         "   (an old folder still online is a real target: in one real case it\n"
                         "    had LESS protection than production, and that was the way in)"),
    "linguagens":       ("\nLinguagens do codigo proprio (o que define a REGUA da auditoria):",
                         "\nLanguages of your own code (this picks the audit RULESET):"),
    "maiores":          ("\nMaiores arquivos PROPRIOS (CONFIRA: se algum for lib OU codigo GERADO/build,\n"
                         "coloque o nome da pasta no escopo.txt com barra no fim (ex.: Objects/) e rode de novo):",
                         "\nLargest OWN files (CHECK: if any is a library OR GENERATED/build output,\n"
                         "put its folder name in escopo.txt with a trailing slash (e.g. Objects/) and run again):"),
    "listas_local":     ("\n" + "=" * 72 + "\nA LISTA DE EXCLUSAO (edite e rode o medir de novo) esta nesta pasta:\n"
                         "   %s\n"
                         "   - escopo.txt ......... o UNICO arquivo que voce edita. Uma linha por\n"
                         "                           item; BARRA no fim = pasta, sem barra = arquivo,\n"
                         "                           ! no inicio = REINCLUI. Biblioteca, gerado (Objects/),\n"
                         "                           duplicado, dado e backup o medidor ja desconta sozinho.\n"
                         "O que sai da conta sai da AUDITORIA.\n" + "=" * 72,
                         "\n" + "=" * 72 + "\nTHE EXCLUSION LIST (edit it and run the meter again) is in this folder:\n"
                         "   %s\n"
                         "   - escopo.txt ......... the ONLY file you edit. One item per line;\n"
                         "                           trailing SLASH = folder, no slash = file,\n"
                         "                           ! at the start = RE-INCLUDE. Libraries, generated\n"
                         "                           (Objects/), duplicates, data and backups are already\n"
                         "                           discounted for you.\n"
                         "What leaves the count leaves the AUDIT.\n" + "=" * 72),
    "pares_copia":      ("\nPASTAS QUE PARECEM COPIA (byte a byte). So VOCE sabe qual esta valendo:",
                         "\nFOLDERS THAT LOOK LIKE COPIES (byte for byte). Only YOU know which counts:"),
    "pares_copia_alvo": ("  ^ leve estes pares para o campo 'pastas que sao COPIA/versao' do ALVO.",
                         "  ^ take these pairs to the 'folders that are COPY/version' field of the TARGET."),
    "detalhe_tit":      ("\n--- DETALHE DOS DESCONTOS ---",
                         "\n--- DISCOUNT DETAIL ---"),
    "detalhe_cat":      ("\n[%s] %s arquivos:",
                         "\n[%s] %s files:"),
    "detalhe_mais":     ("  ... e mais %s arquivos",
                         "  ... and %s more files"),
    "manifesto_ok":     ("\nManifesto gravado em: %s",
                         "\nManifest written to: %s"),
    "aviso_nomes":      ("\nATENCAO: pasta com OUTRO nome (new/, teste/, v2/, copia-joao/) NAO e reconhecida\n"
                         "como backup e conta como codigo proprio. Se for codigo morto, acrescente o nome\n"
                         "no escopo.txt (com barra: new/) - lembrando que o que sai da conta sai da AUDITORIA.",
                         "\nWARNING: a folder with ANOTHER name (new/, test/, v2/, copy-john/) is NOT\n"
                         "recognised as backup and counts as your own code. If it is dead code, add the\n"
                         "name to escopo.txt (with a slash: new/) - remembering what leaves the count\n"
                         "leaves the AUDIT."),

    # --- COMO PREENCHER O PEDIDO
    "ped_titulo":       ("COMO PREENCHER O PEDIDO",
                         "HOW TO FILL IN THE ORDER"),
    "ped_modelo":       ("  1. Modelo",
                         "  1. Model"),
    "ped_code":         ("       Code .......... o codigo fica na SUA maquina",
                         "       Code .......... the code stays on YOUR machine"),
    "ped_code_online":  ("       Code Online ... auditamos o codigo no servidor onde ele RODA.\n"
                         "                       Exige o Claude Code rodando DENTRO do servidor;\n"
                         "                       nao sendo possivel, o produto e o Code.",
                         "       Code Online ... we audit the code on the server where it RUNS.\n"
                         "                       Requires Claude Code running INSIDE the server;\n"
                         "                       if that is not possible, the product is Code."),
    "ped_nivel":        ("  2. Nivel ........... ONE  ou  Deep",
                         "  2. Level ........... ONE  or  Deep"),
    "ped_linguagem":    ("  3. Linguagem ....... %s   (dominante, medida acima)",
                         "  3. Language ........ %s   (dominant, measured above)"),
    "ped_tamanho":      ("  4. Tamanho ......... %s linhas de codigo proprio",
                         "  4. Size ............ %s lines of own code"),
    "ped_anexo":        ("  5. Anexo ........... %s",
                         "  5. Attachment ...... %s"),
    # O nome do arquivo vem do %s — cravar "manifesto.json" no texto fazia a
    # linha mentir quando o cliente usava `--manifesto outro.json`.
    "ped_anexo_ok":     ("%s (gerado agora, nesta pasta)",
                         "%s (just written, in this folder)"),
    "ped_anexo_falta":  ("nenhum ainda - gere a PROVA da medicao com:\n"
                         "                       python medir.py %s --manifesto manifesto.json\n"
                         "                       Sem ele o tamanho e declaracao sua; com ele, e medida.",
                         "none yet - produce the PROOF of the measurement with:\n"
                         "                       python medir.py %s --manifesto manifesto.json\n"
                         "                       Without it the size is your word; with it, it is measured."),
    "ped_parcial":      ("  !! MODO PARCIAL: o tamanho acima e o do RECORTE, nao do sistema.\n"
                         "     Diga isso no pedido - escopo parcial e auditoria parcial, e o\n"
                         "     que ficou de fora nao e olhado.",
                         "  !! PARTIAL MODE: the size above is the SLICE, not the system.\n"
                         "     Say so in the order - a partial scope is a partial audit, and\n"
                         "     what was left out is not looked at."),
    "ped_link":         ("  Pedido:  %s",
                         "  Order:   %s"),
    "ped_email":        ("  Duvida:  %s",
                         "  Doubts:  %s"),
    "rev_titulo":       ("ANTES DE PEDIR: CONFIRA O ALCANCE",
                         "BEFORE ORDERING: CHECK THE SCOPE"),
    "rev_1":            ("  A lista acima e a sua decisao, nao a nossa. Releia os DESCONTOS e os",
                         "  The list above is your decision, not ours. Re-read the DISCOUNTS and the"),
    "rev_2":            ("  MAIORES ARQUIVOS: o que ficou de fora nao sera auditado, e o que entrou",
                         "  LARGEST FILES: what was left out will not be audited, and what went in"),
    "rev_3":            ("  sera. Depois do pedido o escopo esta fechado.  ->  escopo.txt",
                         "  will be. Once ordered, the scope is closed.     ->  escopo.txt"),
    "ped_idioma":       ("  (esta saida esta em portugues. Para ingles: --lang en)",
                         "  (this output is in English. For Portuguese: --lang pt)"),
}


# 🔴 GUARDA DE BUILD, igual a do gerador do site: um lado vazio, ou um numero
# de '%s' diferente entre PT e EN, quebraria SO no idioma que ninguem testou -
# e do lado do cliente. Falha aqui, alto, antes de rodar qualquer coisa.
def _confere_traducoes():
    espec = re.compile(r"%[-#0 +]*[0-9.*]*[a-zA-Z]")
    ruins = []
    for k in sorted(TXT):
        v = TXT[k]
        if len(v) != 2 or not v[0].strip() or not v[1].strip():
            ruins.append("%s (lado vazio)" % k)
        elif len(espec.findall(v[0])) != len(espec.findall(v[1])):
            ruins.append("%s (numero de %%s diferente entre PT e EN)" % k)
    if ruins:
        raise SystemExit("BUG NO MEDIDOR - traducao incompleta:\n  " + "\n  ".join(ruins))


_confere_traducoes()


def _(chave, *args):
    """Texto no idioma corrente. KeyError e PROPOSITAL: traducao que falta
    tem de estourar aqui, nao imprimir a chave e passar despercebida."""
    s = TXT[chave][1 if IDIOMA == "en" else 0]
    return (s % args) if args else s


def url_pedido():
    return "https://svo.com.br/en/#contato" if IDIOMA == "en" else "https://svo.com.br/#contato"


def bloco_pedido(linhas, lang_dominante, parcial, manifesto_path, alvo):
    """O fecho da saida: transforma a medicao nos campos do pedido.
    Sem isto o cliente termina com numeros e sem saber o que fazer com eles."""
    print("\n" + "=" * 70)
    print(_("ped_titulo"))
    print("=" * 70)
    print(_("ped_modelo"))
    print(_("ped_code"))
    print(_("ped_code_online"))
    print(_("ped_nivel"))
    print(_("ped_linguagem", lang_dominante or "-"))
    print(_("ped_tamanho", fmt(linhas)))
    if manifesto_path:
        print(_("ped_anexo", _("ped_anexo_ok", manifesto_path)))
    else:
        print(_("ped_anexo", _("ped_anexo_falta", alvo)))
    if parcial:
        print("")
        print(_("ped_parcial"))
    print("")
    print(_("ped_link", url_pedido()))
    print(_("ped_email", CONTATO))
    print(_("ped_idioma"))
    print("")
    # 🔴 A ULTIMA COISA QUE O CLIENTE LE E A DECISAO DELE, NAO A NOSSA VENDA.
    # (Humberto, 2026-08-27, depois do run no Mac com um cliente que revisa item
    # a item.) A frase "o que sai da conta sai da AUDITORIA" estava no MEIO da
    # tela, e o rodape era "como preencher o pedido". Mas o escopo E A
    # AUTORIZACAO: depois do pedido ele congela. Entao ele fecha a tela.
    print("=" * 70)
    print(_("rev_titulo"))
    print("=" * 70)
    print(_("rev_1"))
    print(_("rev_2"))
    print(_("rev_3"))
    print("")


def fmt(n):
    # O separador de milhar tambem e idioma: "21.405" lido por um americano e
    # vinte e um virgula quatro. Numero de tamanho de sistema errado por fator
    # de mil nao e detalhe de formatacao - e o campo do pedido.
    return "{:,}".format(n) if IDIOMA == "en" else "{:,}".format(n).replace(",", ".")


def _parse_escopo(linhas):
    """Interpreta o escopo.txt (sintaxe unica, estilo .gitignore).
    A BARRA no fim = PASTA; sem barra = ARQUIVO; uma '/' no meio = caminho
    especifico. O '!' na frente REINCLUI (força auditar algo que o medidor
    excluiria por default — ex.: uma pasta 'backup_x/' que e codigo vivo, ou um
    'bootstrap.js' que e codigo SEU e nao a biblioteca).

    🔴 ACHADO G1 (triagem 2026-08-24, MEDIDO). Ate aqui, '!' em ARQUIVO fazia o
    OPOSTO do que o cliente pediu: o '!' era removido e a linha caia na lista de
    EXCLUSAO, com o motivo "excluido a seu pedido" — ou seja, ele pedia para
    auditar, o arquivo saia da auditoria, e o manifesto creditava a decisao a ele.
    Medido: sem escopo.txt, 'resources/js/bootstrap.js' saia como 'biblioteca';
    com '!bootstrap.js', saia como 'excluido a seu pedido'.

    Devolve (dir_nomes, dir_cam, arq_cam, arq_globs, reinc_nomes, reinc_cam,
             reinc_arq_cam, reinc_arq_globs, cruas)."""
    dir_nomes, dir_cam, arq_cam, arq_globs = set(), [], set(), []
    reinc_nomes, reinc_cam, cruas = set(), [], []
    reinc_arq_cam, reinc_arq_globs = set(), []
    for l in linhas:
        l = l.strip().replace("\\", "/")
        if l.startswith("./"):
            l = l[2:]
        if not l or l.startswith("#"):
            continue
        cruas.append(l)
        reincluir = l.startswith("!")
        if reincluir:
            l = l[1:].strip()
        if l.endswith("/"):                       # PASTA
            corpo = l.rstrip("/")
            if not corpo:
                continue
            if reincluir:
                (reinc_cam.append(corpo) if "/" in corpo else reinc_nomes.add(corpo))
            elif "/" in corpo:
                dir_cam.append(corpo)             # caminho especifico
            else:
                dir_nomes.add(corpo)              # nome em qualquer lugar
        else:                                     # ARQUIVO
            if reincluir:                         # G1: '!' em arquivo REINCLUI
                (reinc_arq_cam.add(l) if "/" in l else reinc_arq_globs.append(l))
            elif "/" in l:
                arq_cam.add(l)
            else:
                arq_globs.append(l)
    return (dir_nomes, dir_cam, arq_cam, arq_globs, reinc_nomes, reinc_cam,
            reinc_arq_cam, reinc_arq_globs, cruas)


def _listas(avisar=True):
    """Garante o escopo.txt em disco e devolve o `ped` ja parseado (uma lista
    unica desde 2026-08-05; os defaults de exclusao moram no codigo)."""
    base = os.path.dirname(os.path.abspath(__file__))
    criados = criar_listas(base)
    if criados and avisar:
        print(_("listas_criei", base))
        for nome in criados:
            print("   %s" % nome)
        print(_("listas_mandam"))
    return _parse_escopo(_load_list(base, "escopo.txt", []))


def _acha_raiz(caminho):
    """Sobe procurando marcador de projeto. Devolve (raiz, codigo, extra).
    Devolve CODIGO, nao frase: a frase muda de idioma, e um `if "sem marcador"
    in motivo` deixaria de valer em ingles - o aviso mais importante da funcao
    sumiria justo para o cliente estrangeiro."""
    d = os.path.dirname(os.path.abspath(caminho))
    atual = d
    while True:
        for marca in (".git", "composer.json", "package.json"):
            if os.path.exists(os.path.join(atual, marca)):
                return atual, "marcador", marca
        pai = os.path.dirname(atual)
        if pai == atual:
            return d, "sem_marcador", ""
        atual = pai


def modo_parcial(semente, raiz_forcada, manifesto_path=None):
    if raiz_forcada:
        raiz, cod_raiz, extra = os.path.abspath(raiz_forcada), "informada", ""
    else:
        raiz, cod_raiz, extra = _acha_raiz(semente)
    motivo = {"marcador": lambda: _("raiz_marcador", extra),
              "sem_marcador": lambda: _("raiz_sem_marcador"),
              "informada": lambda: _("raiz_informada")}[cod_raiz]()
    sem_abs = os.path.abspath(semente)
    if not sem_abs.lower().startswith(raiz.lower()):
        print(_("err_semente_fora", sem_abs, raiz))
        return 2
    sem_rel = os.path.relpath(sem_abs, raiz).replace(os.sep, "/")

    ped = _listas()
    arquivos = medir(raiz, ped)
    mapa = {a["rel"]: a for a in arquivos}
    if sem_rel not in mapa:
        print(_("err_semente_med", sem_rel))
        return 2

    grafo, reverso, nao_res = arestas(raiz, arquivos)
    # O RECORTE, em tres movimentos:
    #   1. quem USA a semente (subindo, transitivo)
    #   2. o que a semente usa (descendo)
    #   3. o que ESSES arquivos usam tambem (descendo a partir de todos)
    # O resultado e um conjunto FECHADO: nenhum arquivo do recorte depende de
    # arquivo de fora - se dependesse, o recorte estaria mentindo.
    usa = alcancaveis([sem_rel], grafo)
    usado_por = alcancaveis([sem_rel], reverso)
    escopo = alcancaveis(usado_por, grafo)

    def soma(conj):
        sel = [mapa[r] for r in conj if r in mapa]
        pro = [a for a in sel if a["categoria"] == CAT_PROPRIO]
        return len(sel), sum(a["sloc"] for a in pro), len(pro)

    tot_rep = totais(arquivos)
    rep_sloc = tot_rep[CAT_PROPRIO]["sloc"]
    rep_arq = tot_rep[CAT_PROPRIO]["arquivos"]

    print(_("cab"))
    print(_("modo_parcial", sem_rel))
    print(_("raiz", raiz, motivo))
    if cod_raiz == "sem_marcador":
        print(_("raiz_aviso"))

    _ig, esc_sloc, esc_arq = soma(escopo)
    cob = (100.0 * esc_sloc / rep_sloc) if rep_sloc else 0
    print(_("recorte"))
    print(_("recorte_num", fmt(esc_arq), fmt(esc_sloc), cob))
    print(_("recorte_def"))
    print(_("repo_inteiro", fmt(rep_sloc), fmt(rep_arq)))
    print(_("repo_porque"))

    # A SEMENTE DECIDE O RECORTE. Semente de BASE (classe de banco, config,
    # auth) e usada por todo mundo => o recorte vira o sistema inteiro, e
    # nao ha nada de parcial nisso. Semente de FLUXO (emissao de boleto,
    # fechamento) traz o que vem antes e o que vem depois DAQUELE fluxo.
    # O medidor nao escolhe por voce - mas avisa qual dos dois voce pegou.
    if cob >= 80:
        print("\n" + "!" * 70)
        print(_("semente_base", cob))
        print("!" * 70)
        print(_("semente_base_txt"))
        print("!" * 70)

    print(_("quem_usa", fmt(len(usado_por) - 1)))
    for r in sorted(usado_por - {sem_rel}):
        print("   %s" % r)

    if usa - {sem_rel}:
        print(_("o_que_usa", fmt(len(usa) - 1)))
        for r in sorted(usa - {sem_rel}):
            print("   %s" % r)

    resto = escopo - usa - usado_por
    if resto:
        print(_("usam_tambem", fmt(len(resto))))
        for r in sorted(resto):
            print("   %s" % r)

    # ------------------------------------------------------------------
    # SEQUENCIAS DE FUNCIONAMENTO - o que o auditor precisa seguir do
    # comeco ao fim. Uma sequencia comeca num arquivo que NINGUEM chama
    # (uma entrada: tela, endpoint, cron) e desce ate onde o fluxo acaba.
    # Sem isto o recorte e uma lista de arquivos; com isto e um fluxo.
    # ------------------------------------------------------------------
    entradas = sorted(r for r in usado_por
                      if not reverso.get(r) and mapa.get(r, {}).get("categoria") == CAT_PROPRIO)
    if sem_rel in usado_por and not reverso.get(sem_rel):
        entradas = sorted(set(entradas) | {sem_rel})

    def caminho_ate(origem, destino):
        """Menor caminho origem -> destino, ou None."""
        ant, fila = {origem: None}, [origem]
        while fila:
            at = fila.pop(0)
            if at == destino:
                cam = []
                while at is not None:
                    cam.append(at)
                    at = ant[at]
                return list(reversed(cam))
            for d, _ig in sorted(grafo.get(at, ())):
                if d not in ant:
                    ant[d] = at
                    fila.append(d)
        return None

    print("\n" + "=" * 70)
    print(_("seq_titulo", fmt(len(entradas))))
    print("=" * 70)
    print(_("seq_txt"))
    for e in entradas:
        cam = caminho_ate(e, sem_rel)
        if not cam:
            continue
        print("\n   " + "\n     -> ".join(cam))
    abaixo = sorted(usa - {sem_rel})
    if abaixo:
        print(_("seq_continua"))
        for r in abaixo:
            print("     -> %s" % r)

    # REFERENCIA CRUZADA - quem chama quem DENTRO do recorte.
    print(_("xref"))
    for r in sorted(escopo):
        if mapa.get(r, {}).get("categoria") != CAT_PROPRIO:
            continue
        usa_r = sorted(d for d, _e in grafo.get(r, ()) if d in escopo)
        por_r = sorted(o for o, _e in reverso.get(r, ()) if o in escopo)
        marca = _("xref_semente") if r == sem_rel else ""
        print("   %s%s" % (r, marca))
        print(_("xref_usa", len(usa_r), ", ".join(usa_r) or "-"))
        print(_("xref_usado", len(por_r), ", ".join(por_r) or "-"))

    fora = sorted(set(mapa) - escopo)
    fora_pro = [r for r in fora if mapa[r]["categoria"] == CAT_PROPRIO]
    print(_("fora_recorte", fmt(len(fora_pro))))
    for r in fora_pro[:15]:
        print("   %s" % r)
    if len(fora_pro) > 15:
        print(_("e_mais", fmt(len(fora_pro) - 15)))

    print(_("fronteiras", fmt(len(nao_res))))
    if not nao_res:
        print(_("nenhuma"))
    for e in nao_res[:15]:
        print("   %s:%s  [%s]" % (e["arquivo"], e["linha"], e["tipo"]))
        print("      %s" % e["trecho"])
    if len(nao_res) > 15:
        print(_("e_mais", fmt(len(nao_res) - 15)))
    print(_("fronteiras_txt"))

    print(_("confira"))

    # O recorte e o que vai ser auditado - entao e ELE que vai no pedido.
    sel = [mapa[r] for r in sorted(escopo) if r in mapa and mapa[r]["categoria"] == CAT_PROPRIO]
    por_lang = {}
    for a in sel:
        por_lang[a["lang"]] = por_lang.get(a["lang"], 0) + a["sloc"]
    lang_dom = max(por_lang.items(), key=lambda x: x[1])[0] if por_lang else ""

    # 🔴 O manifesto do modo PARCIAL. Ate 2026-08-02 o --manifesto era aceito
    # aqui e IGNORADO EM SILENCIO: saida zero, nenhum arquivo, nenhum aviso -
    # indistinguivel de ter funcionado. E o recorte e justamente o caso em que
    # a prova importa MAIS, porque o que esta em disputa e o proprio escopo.
    if manifesto_path:
        man = {
            "scope_mode": "PARCIAL",
            "medidor_versao": MEDIDOR_VERSAO,
            "medidor_sha256": _medidor_sha256(),
            "root": raiz,
            "seed": sem_rel,
            "totals": {
                "recorte_sloc": esc_sloc,
                "recorte_arquivos": esc_arq,
                "repositorio_sloc": rep_sloc,
                "repositorio_arquivos": rep_arq,
                "cobertura_pct": round(cob, 2),
            },
            "languages": por_lang,
            "files": sel,
            # As fronteiras vao no manifesto de proposito: cada uma e um lugar
            # onde a analise estatica parou. Omiti-las venderia o recorte como
            # fechado quando ele tem pontas soltas declaradas.
            "fronteiras_nao_resolvidas": nao_res,
        }
        # 🔴 ACHADO 3 (auditoria ONE, 2026-08-23): antes isto sobrescrevia um manifesto existente
        # sem aviso e sem copia. O manifesto e a PROVA da medicao que fecha o orcamento — perder o
        # anterior por rodar o comando de novo apagava a prova, em silencio.
        if os.path.exists(manifesto_path):
            _bak = "%s.bak-%s" % (manifesto_path, time.strftime("%Y%m%d%H%M%S"))
            try:
                shutil.copy2(manifesto_path, _bak)
                print("  [aviso] ja havia um %s - o anterior foi guardado em %s"
                      % (manifesto_path, _bak))
            except OSError as ex:
                print("  [aviso] ja havia um %s e NAO consegui copiar (%s) - sobrescrevendo"
                      % (manifesto_path, ex))
        # A1, terceira perna: falha de escrita nao pode virar traceback DEPOIS de
        # toda a medicao ja impressa. O numero na tela continua valendo; o que
        # falta e a PROVA, e isso se diz em vez de estourar.
        try:
            with open(manifesto_path, "w", encoding="utf-8", newline="\n") as fh:
                json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True)
            print(_("manifesto_ok", manifesto_path))
        except OSError as ex:
            print(_("err_manif_escrita", manifesto_path, ex))
            return 2

    bloco_pedido(esc_sloc, lang_dom, True, manifesto_path, semente)
    return 0


def _valor_de_flag(args, flag, chave_erro):
    """Consome `flag <valor>` de `args`. Devolve (valor, erro).

    🔴 ACHADO A1 (triagem 2026-08-24, MEDIDO). Antes, a unica checagem era
    `i+1 >= len(args)`: se o token seguinte fosse OUTRA FLAG, ele virava o valor,
    e o `del args[i:i+2]` engolia junto o caminho do projeto. Medido:
    `--manifesto --lang en app/index.php` gravou o manifesto JSON POR CIMA do
    `app/index.php` e saiu com 0. Valor que comeca com '-' agora e RECUSADO.
    """
    if flag not in args:
        return None, None
    i = args.index(flag)
    if i + 1 >= len(args):
        return None, _(chave_erro)
    valor = args[i + 1]
    if valor.startswith("-"):
        return None, _("err_flag_valor", flag, valor, flag)
    del args[i:i + 2]
    return valor, None


def _pode_gravar_manifesto(caminho):
    """Erro (string) se gravar aqui destruiria codigo do cliente; None se pode.

    Complementa o A1 pelo outro lado: mesmo com as flags certas, um caminho de
    saida digitado errado (`--manifesto app/index.php`) apagaria o fonte. O
    manifesto e um .json; recusar sobrescrever arquivo de CODIGO nao tira
    nenhum uso legitimo, e e a unica trava que impede perda de fonte.
    """
    if not os.path.exists(caminho):
        return None
    ext = os.path.splitext(caminho)[1].lower()
    if ext in EXT_LANG:
        return _("err_manif_codigo", caminho, ext)
    return None


def main():
    # SVO_HELP (2026-08-31): `--help` era medido como se fosse caminho, e o cliente
    # que digitasse o instinto universal recebia a medicao da pasta errada em vez
    # da ajuda. O texto de uso ja existia no topo do arquivo -- faltava alguem
    # imprimi-lo.
    if any(a in ("--help", "-h", "/?", "--ajuda") for a in sys.argv[1:]):
        print(__doc__ or "")
        return 0
    global IDIOMA
    args = [a for a in sys.argv[1:]]

    # IDIOMA primeiro: os proprios erros de linha de comando ja saem no idioma
    # certo. Um "ERRO:" em portugues para quem pediu --lang en seria a primeira
    # coisa que o cliente estrangeiro leria.
    IDIOMA = _detecta_idioma()
    lang_declarado = False
    if "--lang" in args:
        i = args.index("--lang")
        if i + 1 >= len(args) or args[i + 1].lower() not in ("pt", "en"):
            print(_("err_lang"))
            return 2
        IDIOMA = args[i + 1].lower()
        lang_declarado = True
        del args[i:i + 2]

    detalhe = "--detalhe" in args
    # 🔴 O MANIFESTO PASSOU A SER O PADRÃO (2026-08-20, Humberto).
    # Ele era opcional, e sem ele o cliente manda um NÚMERO sem prova: o
    # orçamento vira estimativa, o escopo não tem lista, e a auditoria perde a
    # verificação de identidade da árvore. O que se ganhava deixando opcional
    # era uma flag a menos no comando — e a flag some agora de qualquer jeito.
    # `--manifesto <nome>` continua valendo, para quem quer outro nome.
    manifesto_path = "manifesto.json"
    v, erro = _valor_de_flag(args, "--manifesto", "err_manifesto")
    if erro:
        print(erro)
        return 2
    if v is not None:
        manifesto_path = v
    raiz_forcada, erro = _valor_de_flag(args, "--raiz", "err_raiz")
    if erro:
        print(erro)
        return 2
    # ⭐ `--path` EXISTE PARA O CAMINHO FICAR NO FIM DA LINHA (Humberto,
    # 2026-08-20): assim o cliente apaga o exemplo e cola o dele na PONTA, em
    # vez de caçar o meio do comando. A forma posicional continua valendo — há
    # comando antigo copiado por aí, e quebrá-lo não melhora a vida de ninguém.
    alvo, erro = _valor_de_flag(args, "--path", "err_path")
    if erro:
        print(erro)
        return 2
    args = [a for a in args if not a.startswith("--")]
    if alvo is None:
        alvo = args[0] if args else "."

    # A1, segunda perna: caminho de saida digitado errado nao pode apagar fonte.
    erro = _pode_gravar_manifesto(manifesto_path)
    if erro:
        print(erro)
        return 2

    # MODO: detectado pelo que voce apontou - e SEMPRE declarado, nunca em silencio.
    if os.path.isfile(alvo):
        return modo_parcial(alvo, raiz_forcada, manifesto_path)
    if not os.path.isdir(alvo):
        print(_("err_caminho", alvo))
        return 2

    # 🔴 IDIOMA ADIVINHADO SE DECLARA. Sem `--lang`, a escolha vem do locale da
    # máquina — e ela JÁ ERROU: um cliente brasileiro recebeu a saída inteira em
    # inglês e só descobriu lendo. Adivinhar é aceitável; adivinhar em silêncio
    # não. Uma linha, com o modo de corrigir ao lado.
    if not lang_declarado:
        print(_("lang_adivinhado"))
    root = os.path.abspath(alvo)
    ped = _listas()
    casou = set()                      # entradas do escopo.txt que pegaram algo
    arquivos = medir(root, ped, casou)
    t = totais(arquivos)
    bruto = sum(v["sloc"] for v in t.values())
    proprio = t[CAT_PROPRIO]["sloc"]

    print(_("cab"))
    print(_("modo_completo", root))
    print(_("bruto", fmt(bruto), fmt(sum(v["arquivos"] for v in t.values()))))

    print(_("descontos"))
    print("  %-12s %12s  %8s   %s" % ("", _("col_linhas"), _("col_arquivos"), ""))
    for cat, chave in ((CAT_DADO, "cat_dado"), (CAT_LIB, "cat_lib"),
                       (CAT_GERADO, "cat_gerado"), (CAT_DUP, "cat_dup"),
                       (CAT_COPIA, "cat_copia"), (CAT_PEDIDO, "cat_pedido"),
                       (CAT_DESCONHECIDO, "cat_desconhecido"), (CAT_ILEGIVEL, "cat_ilegivel")):
        v = t[cat]
        pct = (100.0 * v["sloc"] / bruto) if bruto else 0
        print("  %-12s %12s  %8s   (%4.1f%%)" % (_(chave), fmt(v["sloc"]), fmt(v["arquivos"]), pct))

    print(_("proprio", fmt(proprio), fmt(t[CAT_PROPRIO]["arquivos"])))
    if bruto:
        print(_("proprio_pct", 100.0 * proprio / bruto))

    # ------------------------------------------------------------------
    # O bloco mais importante da saida: excluir NAO e so pagar menos.
    # Pasta excluida NAO E AUDITADA. Quem decide e o cliente, e para decidir
    # ele precisa VER a lista - por isso ela e impressa, nunca so contada.
    copias = {}
    for a in arquivos:
        if a["categoria"] == CAT_COPIA:
            v = copias.setdefault(a["pasta_copia"], {"arquivos": 0, "sloc": 0})
            v["arquivos"] += 1
            v["sloc"] += a["sloc"]
    if copias:
        print("\n" + "!" * 72)
        print(_("copias_titulo"))
        print("!" * 72)
        print(_("copias_txt"))
        print("")
        for pasta, v in sorted(copias.items(), key=lambda kv: -kv[1]["sloc"]):
            print("  %9s %-9s %5s   %s" % (fmt(v["sloc"]), _("col_linhas"),
                                           fmt(v["arquivos"]), pasta))
        print("")
        print(_("copias_pergunta"))
        print("!" * 72)

    # O QUE O CLIENTE PEDIU PARA EXCLUIR. Bloco separado do de copias de proposito:
    # aquele e DEDUCAO NOSSA (default do medidor; ele reinclui com !nome/); este e
    # DECISAO DELE (o que ele mesmo escreveu no escopo.txt). Nao misturar os motivos:
    # motivo errado faz o cliente discutir a conta errada.
    grupos_ped = {}
    for a in arquivos:
        if a["categoria"] == CAT_PEDIDO:
            v = grupos_ped.setdefault(a["motivo"], {"arquivos": 0, "sloc": 0})
            v["arquivos"] += 1
            v["sloc"] += a["sloc"]
    if grupos_ped:
        print("\n" + "!" * 72)
        print(_("pedido_titulo"))
        print("!" * 72)
        print(_("pedido_txt"))
        print("")
        for mot, v in sorted(grupos_ped.items(), key=lambda kv: -kv[1]["sloc"]):
            print("  %9s %-9s %5s   %s" % (fmt(v["sloc"]), _("col_linhas"),
                                           fmt(v["arquivos"]), mot))
        print("")
        print(_("pedido_desfaz"))
        print("!" * 72)

    # 🔴 LINHA QUE NAO CASOU COM NADA. Sem este aviso, um erro de digitacao no
    # escopo.txt e INDISTINGUIVEL de ter funcionado: o cliente le "excluido a seu
    # pedido: 0 linhas" como se a pasta fosse pequena, e a pasta segue na auditoria.
    # `casou` (preenchido por medir) tem as entradas que pegaram algo; `ped[6]` sao
    # as linhas cruas. A diferenca sao as que nao casaram — normalizando a barra.
    orfaos = [c for c in ped[8] if c not in casou]
    if orfaos:
        print("\n" + "!" * 72)
        print(_("pedido_nada", fmt(len(orfaos))))
        for p in orfaos:
            print("   %s" % p)
        print("!" * 72)

    proprios = [a for a in arquivos if a["categoria"] == CAT_PROPRIO]
    per_lang = {}
    for a in proprios:
        per_lang[a["lang"]] = per_lang.get(a["lang"], 0) + a["sloc"]
    print(_("linguagens"))
    for lang, n in sorted(per_lang.items(), key=lambda x: -x[1]):
        pct = (100.0 * n / proprio) if proprio else 0
        print("  %-14s %10s  (%4.1f%%)" % (lang, fmt(n), pct))

    maiores = sorted(proprios, key=lambda a: -a["sloc"])[:8]
    if maiores:
        print(_("maiores"))
        for a in maiores:
            print("  %8s  %s" % (fmt(a["sloc"]), a["rel"]))

    pares = clusters_duplicados(arquivos)
    if pares:
        print(_("pares_copia"))
        for (orig, dest), v in pares[:8]:
            print("  %6s / %9s   %s  ->  %s" % (fmt(v["arquivos"]), fmt(v["sloc"]), orig, dest))
        print(_("pares_copia_alvo"))

    if detalhe:
        print(_("detalhe_tit"))
        for cat in (CAT_DADO, CAT_LIB, CAT_GERADO, CAT_DUP, CAT_COPIA, CAT_PEDIDO,
             CAT_DESCONHECIDO, CAT_ILEGIVEL):
            itens = sorted([a for a in arquivos if a["categoria"] == cat],
                           key=lambda a: -a["sloc"])
            if not itens:
                continue
            print(_("detalhe_cat", cat, fmt(len(itens))))
            for a in itens[:30]:
                print("  %8s  %-60s  %s" % (fmt(a["sloc"]), a["rel"], a["motivo"]))
            if len(itens) > 30:
                print(_("detalhe_mais", fmt(len(itens) - 30)))

    if manifesto_path:
        man = {
            "scope_mode": "COMPLETO",
            "medidor_versao": MEDIDOR_VERSAO,
            "medidor_sha256": _medidor_sha256(),
            "root": root,
            "totals": {
                "bruto": bruto,
                "proprio": proprio,
                "descontos": {c: t[c] for c in
                              (CAT_DADO, CAT_LIB, CAT_GERADO, CAT_DUP, CAT_COPIA, CAT_PEDIDO,
             CAT_DESCONHECIDO, CAT_ILEGIVEL)},
                "arquivos": len(arquivos),
            },
            # 🔴 O que o CLIENTE mandou tirar, em campo proprio. Sem isto o relatorio
            # nao consegue separar "nao auditamos porque era lib" de "nao auditamos
            # porque o dono pediu" - e a segunda e a unica que ele precisa reconhecer
            # ao ler a secao de limites.
            # 🔴 ACHADO G1/F3 (triagem 2026-08-24): aqui ia `ped[cruas]` INTEIRO —
            # inclusive as linhas de REINCLUSAO. O manifesto dizia que o cliente
            # mandou EXCLUIR aquilo que ele mandou AUDITAR. Agora sao dois campos.
            "excluido_a_pedido": sorted(c for c in ped[8] if not c.startswith("!")),
            "reincluido_a_pedido": sorted(c for c in ped[8] if c.startswith("!")),
            "languages": per_lang,
            "files": arquivos,
        }
        # 🔴 ACHADO 3 (auditoria ONE, 2026-08-23): antes isto sobrescrevia um manifesto existente
        # sem aviso e sem copia. O manifesto e a PROVA da medicao que fecha o orcamento — perder o
        # anterior por rodar o comando de novo apagava a prova, em silencio.
        if os.path.exists(manifesto_path):
            _bak = "%s.bak-%s" % (manifesto_path, time.strftime("%Y%m%d%H%M%S"))
            try:
                shutil.copy2(manifesto_path, _bak)
                print("  [aviso] ja havia um %s - o anterior foi guardado em %s"
                      % (manifesto_path, _bak))
            except OSError as ex:
                print("  [aviso] ja havia um %s e NAO consegui copiar (%s) - sobrescrevendo"
                      % (manifesto_path, ex))
        # A1, terceira perna: falha de escrita nao pode virar traceback DEPOIS de
        # toda a medicao ja impressa. O numero na tela continua valendo; o que
        # falta e a PROVA, e isso se diz em vez de estourar.
        try:
            with open(manifesto_path, "w", encoding="utf-8", newline="\n") as fh:
                json.dump(man, fh, ensure_ascii=False, indent=2, sort_keys=True)
            print(_("manifesto_ok", manifesto_path))
        except OSError as ex:
            print(_("err_manif_escrita", manifesto_path, ex))
            return 2

    print(_("aviso_nomes"))

    # 🔴 SEMPRE dizer ONDE estao as listas e COMO usa-las — nao so na 1a execucao
    # (quando os .txt sao criados). O bloco "maiores" manda "ponha no excludes"
    # mas o cliente nao sabe qual arquivo nem onde: a acao sem o como e a lacuna
    # que a regra 8b existe pra fechar. (Achado: Humberto rodando o lockseed, com
    # 91% do SLOC em Objects/ do B4X contado como proprio.)
    print(_("listas_local", os.path.dirname(os.path.abspath(__file__))))

    lang_dom = max(per_lang.items(), key=lambda x: x[1])[0] if per_lang else ""
    bloco_pedido(proprio, lang_dom, False, manifesto_path, alvo)
    return 0


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