"""Cadastros em árvore no padrão do APP32: Plano de Contas, Centro de Custo e Ambiente.

O código é montado pela árvore (`1` → `1.1` → `1.1.01` → `1.1.01.001`): cada nó guarda só o **sufixo** e o pai, e
`codigo` = pai.codigo + "." + sufixo (raiz: o próprio sufixo). Nó **sintético** (`aceita_lancamento` False) só agrupa;
**analítico** recebe gastos, despesas, recursos. Mudar o sufixo ou o pai refaz o código de toda a subárvore.
Quem chama decide o `commit`; aqui só há `flush`.
"""

import re
from dataclasses import dataclass

from sqlalchemy import func
from sqlalchemy.orm import Session

from custos_precificacao.models import (
    Ambiente,
    AmbienteGasto,
    CentroDeCusto,
    Despesa,
    Funcionario,
    FuncionarioGasto,
    Maquina,
    MaquinaGasto,
    PlanoDeContas,
    RoteiroAmbiente,
)

# largura (zeros à esquerda) do sufixo por nível: 1, 1.1, 1.1.01, 1.1.01.001, ...
_LARGURAS = (1, 1, 2, 3)
_SUFIXO = re.compile(r"^\d{1,6}$")


class ErroDeArvore(ValueError):
    """Violação de regra da árvore (pai inválido, ciclo, sufixo repetido...). A API devolve 422."""


class ErroEmUso(ErroDeArvore):
    """Exclusão/mudança bloqueada porque o nó tem filhos ou lançamentos. A API devolve 409."""


@dataclass(frozen=True)
class Entidade:
    nome: str  # rótulo para mensagens ("Conta", "Centro de Custo", "Instalação")
    modelo: type
    # (rótulo, modelo, coluna FK) — onde o nó é usado
    usos: tuple[tuple[str, type, str], ...]


ENTIDADES: dict[str, Entidade] = {
    "plano": Entidade("Conta", PlanoDeContas, (
        ("gastos de Mão de obra", FuncionarioGasto, "plano_de_contas_id"),
        ("gastos de Máquina", MaquinaGasto, "plano_de_contas_id"),
        ("gastos de Instalação", AmbienteGasto, "plano_de_contas_id"),
        ("despesas", Despesa, "plano_de_contas_id"),
    )),
    "centro": Entidade("Centro de Custo", CentroDeCusto, (
        ("mão de obra", Funcionario, "centro_de_custo_id"),
        ("máquinas", Maquina, "centro_de_custo_id"),
        ("instalações", Ambiente, "centro_de_custo_id"),
        ("despesas", Despesa, "centro_de_custo_id"),
    )),
    "ambiente": Entidade("Instalação", Ambiente, (
        ("máquinas", Maquina, "ambiente_id"),
        ("gastos", AmbienteGasto, "ambiente_id"),
        ("linhas de roteiro", RoteiroAmbiente, "ambiente_id"),
    )),
}
_POR_MODELO = {e.modelo: e for e in ENTIDADES.values()}


def entidade_de(modelo: type) -> Entidade:
    return _POR_MODELO[modelo]


# ---- consulta

def filhos_de(session: Session, modelo: type, no_id: int | None) -> list:
    consulta = session.query(modelo).filter(modelo.parent_id == no_id) if no_id is not None else session.query(modelo).filter(modelo.parent_id.is_(None))
    return consulta.order_by(modelo.id).all()


def nivel_de(session: Session, no) -> int:
    """1 = raiz."""
    nivel, atual, visitados = 1, no, set()
    while atual.parent_id is not None and atual.id not in visitados:
        visitados.add(atual.id)
        atual = session.get(type(atual), atual.parent_id)
        nivel += 1
    return nivel


def descendentes(session: Session, no) -> list:
    """Todos os nós abaixo de `no` (largura primeiro)."""
    modelo, achados, fila = type(no), [], [no.id]
    while fila:
        lote = session.query(modelo).filter(modelo.parent_id.in_(fila)).order_by(modelo.id).all()
        achados.extend(lote)
        fila = [n.id for n in lote]
    return achados


def contar_usos(session: Session, no) -> dict[str, int]:
    """Quantos lançamentos/recursos usam o nó diretamente (só os que não são zero)."""
    resultado: dict[str, int] = {}
    for rotulo, modelo, coluna in entidade_de(type(no)).usos:
        n = session.query(func.count(modelo.id)).filter(getattr(modelo, coluna) == no.id).scalar() or 0
        if n:
            resultado[rotulo] = n
    return resultado


def usos_em_massa(session: Session, modelo: type) -> dict[int, int]:
    """id do nó -> total de lançamentos/recursos que o usam (uma consulta por tabela, não por nó)."""
    total: dict[int, int] = {}
    for _rotulo, uso, coluna in entidade_de(modelo).usos:
        for no_id, n in session.query(getattr(uso, coluna), func.count(uso.id)).group_by(getattr(uso, coluna)).all():
            if no_id is not None:
                total[no_id] = total.get(no_id, 0) + n
    return total


def chave_de_ordem(codigo: str) -> tuple:
    """Ordena 2.1.2 antes de 2.1.10 (trechos numéricos como número)."""
    return tuple((0, int(t), "") if t.isdigit() else (1, 0, t) for t in codigo.split("."))


def total_de_usos(session: Session, no) -> int:
    return sum(contar_usos(session, no).values())


def _descricao_usos(usos: dict[str, int]) -> str:
    return ", ".join(f"{n} {rotulo}" for rotulo, n in usos.items())


# ---- código

def largura_do_nivel(nivel: int) -> int:
    return _LARGURAS[nivel - 1] if nivel <= len(_LARGURAS) else _LARGURAS[-1]


def montar_codigo(pai, sufixo: str) -> str:
    return sufixo if pai is None else f"{pai.codigo}.{sufixo}"


def validar_sufixo(sufixo: str) -> str:
    sufixo = (sufixo or "").strip()
    if not _SUFIXO.match(sufixo):
        raise ErroDeArvore("O sufixo do código deve ter só dígitos (até 6), como 1, 01 ou 001")
    return sufixo


def proximo_sufixo(session: Session, modelo: type, pai) -> str:
    """Maior sufixo numérico entre os irmãos + 1, com zeros à esquerda conforme o nível."""
    nivel = 1 if pai is None else nivel_de(session, pai) + 1
    maior = 0
    for irmao in filhos_de(session, modelo, None if pai is None else pai.id):
        if irmao.sufixo and irmao.sufixo.isdigit():
            maior = max(maior, int(irmao.sufixo))
    return str(maior + 1).zfill(largura_do_nivel(nivel))


# ---- regras

def centros_produtivos(session: Session) -> set[int]:
    """Ids dos Centros de Custo produtivos (custo): marcados como produtivos **e** sem nenhum ancestral não produtivo."""
    centros = {c.id: c for c in session.query(CentroDeCusto)}
    resultado: set[int] = set()
    for centro in centros.values():
        atual, vistos, produtivo = centro, set(), True
        while atual is not None and atual.id not in vistos:
            vistos.add(atual.id)
            if not atual.produtivo:
                produtivo = False
                break
            atual = centros.get(atual.parent_id)
        if produtivo:
            resultado.add(centro.id)
    return resultado


def exigir_lancavel(no, rotulo: str | None = None) -> None:
    """Lançamento (gasto, despesa, vínculo de recurso) só em nó analítico e ativo."""
    if no is None or type(no) not in _POR_MODELO:
        return
    nome = rotulo or entidade_de(type(no)).nome
    if not no.aceita_lancamento:
        raise ErroDeArvore(f"{nome} {no.codigo} é sintético(a): só agrupa e não recebe lançamentos. Escolha um nó analítico")
    if not no.ativo:
        raise ErroDeArvore(f"{nome} {no.codigo} está inativo(a)")


def _validar_pai(session: Session, no, pai) -> None:
    if pai is None:
        return
    if no is not None and (pai.id == no.id or pai.id in {d.id for d in descendentes(session, no)}):
        raise ErroDeArvore("O pai não pode ser o próprio nó nem um descendente dele (ciclo)")
    usos = contar_usos(session, pai)
    if usos:
        raise ErroEmUso(
            f"{entidade_de(type(pai)).nome} {pai.codigo} já tem lançamentos ({_descricao_usos(usos)}) e não pode ter filhos; "
            "crie um nó sintético acima e mova este para dentro dele"
        )


def _checar_codigo_livre(session: Session, modelo: type, codigo: str, ignorar_ids: set[int]) -> None:
    existente = session.query(modelo).filter(modelo.codigo == codigo).first()
    if existente is not None and existente.id not in ignorar_ids:
        raise ErroDeArvore(f"Já existe um nó com o código {codigo}")


def _herdar_tipo(session: Session, no, pai) -> None:
    """Plano de Contas: filho herda o `tipo` do pai; mudar o tipo de um nó leva a subárvore junto."""
    if not isinstance(no, PlanoDeContas):
        return
    if pai is not None:
        no.tipo = pai.tipo
    for d in descendentes(session, no):
        d.tipo = no.tipo


def recodificar(session: Session, no, ignorar_ids: set[int] | None = None) -> None:
    """Refaz o código do nó e de toda a subárvore a partir do pai e do sufixo (duas fases: evita colisão no único)."""
    modelo = type(no)
    pai = session.get(modelo, no.parent_id) if no.parent_id is not None else None
    sufixo = validar_sufixo(no.sufixo or "")
    novos: list[tuple[object, str]] = [(no, montar_codigo(pai, sufixo))]
    fila = [(no, novos[0][1])]
    while fila:
        atual, codigo = fila.pop(0)
        for filho in filhos_de(session, modelo, atual.id):
            codigo_filho = f"{codigo}.{validar_sufixo(filho.sufixo or '')}"
            novos.append((filho, codigo_filho))
            fila.append((filho, codigo_filho))
    ids = {n.id for n, _ in novos}
    finais = [c for _, c in novos]
    if len(set(finais)) != len(finais):
        raise ErroDeArvore("Dois nós ficariam com o mesmo código")
    for codigo in finais:
        _checar_codigo_livre(session, modelo, codigo, ids | (ignorar_ids or set()))
    if all(n.codigo == c for n, c in novos):
        return
    for n, _ in novos:  # fase 1: libera os códigos
        n.codigo = f"TMP-{n.id}"
    session.flush()
    for n, c in novos:  # fase 2: grava os definitivos
        n.codigo = c
    session.flush()


# ---- operações

def criar_no(session: Session, modelo: type, *, nome: str, pai_id: int | None = None, sufixo: str | None = None,
             aceita_lancamento: bool = True, ativo: bool = True, chave: str | None = None, **extras):
    """Cria um nó; o código é pai.codigo + sufixo (sufixo omitido = próximo livre do nível)."""
    if not (nome or "").strip():
        raise ErroDeArvore("Informe o nome")
    pai = session.get(modelo, pai_id) if pai_id is not None else None
    if pai_id is not None and pai is None:
        raise ErroDeArvore("O pai informado não existe")
    _validar_pai(session, None, pai)
    sufixo = validar_sufixo(sufixo) if sufixo else proximo_sufixo(session, modelo, pai)
    codigo = montar_codigo(pai, sufixo)
    _checar_codigo_livre(session, modelo, codigo, set())
    no = modelo(codigo=codigo, sufixo=sufixo, nome=nome.strip(), parent_id=pai.id if pai else None,
                aceita_lancamento=aceita_lancamento, ativo=ativo, chave=chave, **extras)
    if isinstance(no, PlanoDeContas):
        no.tipo = pai.tipo if pai is not None else extras.get("tipo")
        if no.tipo not in ("receita", "custo", "despesa", "imposto"):
            raise ErroDeArvore("Informe o tipo (receita, custo, despesa ou imposto) da conta raiz")
    session.add(no)
    if pai is not None and pai.aceita_lancamento:
        pai.aceita_lancamento = False  # quem ganha filho vira sintético
    session.flush()
    return no


def atualizar_no(session: Session, no, mudancas: dict):
    """Aplica `mudancas` (nome, sufixo, parent_id, aceita_lancamento, ativo, tipo e extras do modelo).
    Mudar sufixo ou pai recodifica a subárvore."""
    modelo = type(no)
    mudancas = dict(mudancas)
    recodificar_antes = False
    if "nome" in mudancas:
        if not (mudancas["nome"] or "").strip():
            raise ErroDeArvore("Informe o nome")
        no.nome = mudancas.pop("nome").strip()
    if "parent_id" in mudancas:
        novo_pai_id = mudancas.pop("parent_id")
        if novo_pai_id != no.parent_id:
            pai = session.get(modelo, novo_pai_id) if novo_pai_id is not None else None
            if novo_pai_id is not None and pai is None:
                raise ErroDeArvore("O pai informado não existe")
            _validar_pai(session, no, pai)
            antigo_pai_id = no.parent_id
            no.parent_id = novo_pai_id
            if pai is not None and pai.aceita_lancamento:
                pai.aceita_lancamento = False
            if pai is not None:
                _herdar_tipo(session, no, pai)
            if "sufixo" not in mudancas:
                irmaos = {i.sufixo for i in filhos_de(session, modelo, novo_pai_id) if i.id != no.id}
                if no.sufixo in irmaos or not no.sufixo:
                    no.sufixo = proximo_sufixo(session, modelo, pai)
            recodificar_antes = True
            _depois_de_sair(session, modelo, antigo_pai_id)
    if "sufixo" in mudancas:
        novo = validar_sufixo(mudancas.pop("sufixo"))
        if novo != no.sufixo:
            no.sufixo = novo
            recodificar_antes = True
    if "aceita_lancamento" in mudancas:
        analitico = bool(mudancas.pop("aceita_lancamento"))
        if analitico != no.aceita_lancamento:
            if analitico and filhos_de(session, modelo, no.id):
                raise ErroDeArvore("Um nó com filhos é sintético; mova ou exclua os filhos antes de torná-lo analítico")
            no.aceita_lancamento = analitico
    if "ativo" in mudancas:
        no.ativo = bool(mudancas.pop("ativo"))
    if "tipo" in mudancas and isinstance(no, PlanoDeContas):
        tipo = mudancas.pop("tipo")
        if no.parent_id is None and tipo != no.tipo:
            no.tipo = tipo
            _herdar_tipo(session, no, None)
    mudancas.pop("tipo", None)
    for campo, valor in mudancas.items():
        setattr(no, campo, valor)
    if not no.aceita_lancamento and total_de_usos(session, no):
        raise ErroEmUso(
            f"{entidade_de(modelo).nome} {no.codigo} já tem lançamentos ({_descricao_usos(contar_usos(session, no))}) e não pode ser sintético(a)")
    session.flush()
    if recodificar_antes:
        recodificar(session, no)
    return no


def _depois_de_sair(session: Session, modelo: type, antigo_pai_id: int | None) -> None:
    """Pai que ficou sem filhos volta a ser analítico (como o APP32: sem filhos não há o que agrupar)."""
    if antigo_pai_id is None:
        return
    session.flush()
    if not filhos_de(session, modelo, antigo_pai_id):
        antigo = session.get(modelo, antigo_pai_id)
        if antigo is not None:
            antigo.aceita_lancamento = True


def excluir_no(session: Session, no) -> None:
    modelo = type(no)
    nome = entidade_de(modelo).nome
    if filhos_de(session, modelo, no.id):
        raise ErroEmUso(f"{nome} {no.codigo} tem filhos; exclua ou mova os filhos antes")
    usos = contar_usos(session, no)
    if usos:
        raise ErroEmUso(f"{nome} {no.codigo} está em uso ({_descricao_usos(usos)}); inative em vez de excluir")
    pai_id = no.parent_id
    session.delete(no)
    session.flush()
    _depois_de_sair(session, modelo, pai_id)
    session.flush()
