"""Rateio das despesas fixas na precificação primária.

Cada Direcionador com despesa fixa (do Planejamento: `cenario_id` nulo) forma um *pool* — a soma do valor das despesas dele — que se divide entre os
produtos da Tabela de Preços (cabeças) que usam a Atividade do Direcionador e têm volume de produção. A divisão é um % por produto que soma 100%:

- sem rateio gravado vale a **sugestão**, calculada pelo critério do grupo (por padrão o da base do Direcionador: custo_primario → Custo × Volume,
  quantidade_produzida → Quantidade, tempo → Tempo; também há Mistura e Igual);
- o gestor pode redistribuir (`definir_rateio`), desde que o total feche em 100%; o gravado vale no lugar da sugestão.

`despesas.calcular_alocacao_despesa` usa estes pesos para a despesa fixa do Planejamento, de modo que preço, créditos, Análise e DRE seguem o rateio.
Os Centros de Resultados não passam por aqui: as despesas adicionais deles são rateadas entre as quantidades do Centro (`volumes`)."""

from dataclasses import dataclass, field
from decimal import Decimal

from sqlalchemy import event
from sqlalchemy.orm import Session

from custos_precificacao.despesas import _peso_custo_primario_por_produto, _peso_quantidade_por_produto, _peso_tempo_por_produto
from custos_precificacao.models import CRITERIOS_RATEIO, Despesa, Direcionador, Produto, RateioDespesa, RateioGrupo, TravaPrimaria

CRITERIO_DA_BASE = {"custo_primario": "custo_volume", "quantidade_produzida": "quantidade", "tempo": "tempo"}
NOME_DO_CRITERIO = {
    "custo_volume": "Custo × Volume", "quantidade": "Quantidade", "mistura": "Mistura (quantidade e custo × volume)", "tempo": "Tempo", "igual": "Igual para todos",
}
PESO_PADRAO = Decimal("0.5")  # na mistura: metade quantidade, metade custo × volume
TOLERANCIA = Decimal("0.0001")  # quanto a soma dos % pode se afastar de 100%
CASAS = Decimal("0.000001")  # o % é gravado com 6 casas (Numeric(9, 6))
CHAVE_CACHE = "rateio_pesos"


# --- trava (o estado completo está em `trava_primaria`) -----------------------------------------------------------------------------

class PrimariaTravada(ValueError):
    """A Precificação Primária está travada: a API responde 409 (conflito de estado) em vez de 422."""


def esta_travada(session: Session) -> bool:
    trava = session.query(TravaPrimaria).first()
    return bool(trava and trava.travada)


def exigir_aberta(session: Session) -> None:
    """PrimariaTravada se a primária está travada: rateio, Meta de Lucro, Preços Definidos e modo dos impostos só mudam depois de reabrir."""
    if esta_travada(session):
        raise PrimariaTravada("A Precificação Primária está travada — reabra para alterar")


# --- pools e critérios ----------------------------------------------------------------------------------------------------------------

@dataclass
class Pool:
    direcionador: Direcionador
    despesas: list[Despesa] = field(default_factory=list)

    @property
    def valor(self) -> Decimal:
        return sum((d.valor or Decimal(0) for d in self.despesas), Decimal(0))


def pools(session: Session) -> list[Pool]:
    """Um pool por Direcionador que tem despesa fixa do Planejamento, na ordem do código do Direcionador."""
    por_direcionador: dict[int, Pool] = {}
    for despesa in session.query(Despesa).filter_by(tipo="fixa", cenario_id=None).order_by(Despesa.id):
        if despesa.direcionador is None:
            continue
        por_direcionador.setdefault(despesa.direcionador_id, Pool(despesa.direcionador)).despesas.append(despesa)
    return sorted(por_direcionador.values(), key=lambda p: p.direcionador.codigo)


def criterio_do_grupo(session: Session, direcionador: Direcionador) -> tuple[str, Decimal]:
    """(critério, parte da quantidade na mistura) do grupo: o escolhido na tela ou, sem escolha, o da base do Direcionador."""
    grupo = session.query(RateioGrupo).filter_by(direcionador_id=direcionador.id).one_or_none()
    if grupo is not None:
        return grupo.criterio, grupo.peso_quantidade if grupo.peso_quantidade is not None else PESO_PADRAO
    return CRITERIO_DA_BASE[direcionador.base], PESO_PADRAO


def elegiveis(session: Session, direcionador: Direcionador) -> list[int]:
    """Produtos que entram no rateio do Direcionador: cabeças da Tabela de Preços que usam a Atividade dele e têm volume de produção."""
    return list(_peso_quantidade_por_produto(session, direcionador.atividade_id))


def _fracoes(pesos: dict[int, Decimal]) -> dict[int, Decimal]:
    total = sum(pesos.values(), Decimal(0))
    return {k: v / total for k, v in pesos.items()} if total else {}


def _sem_peso(direcionador: Direcionador) -> ValueError:
    return ValueError(
        f"Nenhum produto com peso > 0 para a Atividade {direcionador.atividade_id} (Direcionador {direcionador.codigo!r}) — não há como ratear a despesa"
    )


def pesos_da_sugestao(session: Session, direcionador: Direcionador, criterio: str | None = None, peso_quantidade: Decimal | None = None) -> dict[int, Decimal]:
    """Pesos (proporcionais ao % de cada produto) da sugestão pelo critério. ValueError se nenhum produto tem peso (ou se o critério pede custeio e não há)."""
    if criterio is None:
        criterio, peso_quantidade = criterio_do_grupo(session, direcionador)
    atividade = direcionador.atividade_id
    base = _peso_quantidade_por_produto(session, atividade)  # as cabeças elegíveis, com a quantidade produzida
    if criterio == "quantidade":
        pesos = base
    elif criterio == "igual":
        pesos = {produto_id: Decimal(1) for produto_id in base}
    elif criterio == "custo_volume":
        pesos = _peso_custo_primario_por_produto(session, atividade)
    elif criterio == "tempo":
        pesos = {k: v for k, v in _peso_tempo_por_produto(session, atividade).items() if k in base}
    elif criterio == "mistura":
        w = PESO_PADRAO if peso_quantidade is None else peso_quantidade
        quantidade, custo = _fracoes(base), _fracoes(_peso_custo_primario_por_produto(session, atividade))
        pesos = {k: w * quantidade.get(k, Decimal(0)) + (1 - w) * custo.get(k, Decimal(0)) for k in quantidade}
    else:
        raise ValueError(f"Critério de rateio {criterio!r} inválido. Opções: {', '.join(CRITERIOS_RATEIO)}")
    pesos = {k: v for k, v in pesos.items() if v}
    if not pesos:
        raise _sem_peso(direcionador)
    return pesos


def pesos_gravados(session: Session, direcionador: Direcionador) -> dict[int, Decimal]:
    """O rateio que o gestor gravou, restrito aos produtos que ainda são elegíveis (vazio se não há nada gravado)."""
    gravado = {r.produto_id: Decimal(r.percentual) for r in session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id)}
    if not gravado:
        return {}
    ids = set(elegiveis(session, direcionador))
    pesos = {k: v for k, v in gravado.items() if k in ids and v > 0}
    if not pesos:
        raise ValueError(
            f"O rateio gravado do Direcionador {direcionador.codigo!r} não cobre nenhum produto que use a Atividade com volume — refaça ou volte à sugestão"
        )
    return pesos


def pesos_efetivos(session: Session, direcionador: Direcionador) -> dict[int, Decimal]:
    """Pesos que valem para o Direcionador: o rateio gravado ou, sem ele, a sugestão. A fração de cada produto é peso ÷ Σ pesos."""
    cache: dict = session.info.setdefault(CHAVE_CACHE, {})
    if direcionador.id not in cache:
        try:
            cache[direcionador.id] = pesos_gravados(session, direcionador) or pesos_da_sugestao(session, direcionador)
        except ValueError as exc:
            cache[direcionador.id] = exc
    resultado = cache[direcionador.id]
    if isinstance(resultado, ValueError):
        raise resultado
    return resultado


def alocar(session: Session, despesa: Despesa) -> dict[int, Decimal]:
    """Valor da despesa fixa do Planejamento que cabe a cada produto (soma = valor da despesa)."""
    pesos = pesos_efetivos(session, despesa.direcionador)
    total = sum(pesos.values(), Decimal(0))
    return {produto_id: despesa.valor * peso / total for produto_id, peso in pesos.items()}


def _descartar_cache(session: Session) -> None:
    session.info.pop(CHAVE_CACHE, None)


event.listen(Session, "after_flush", lambda session, _contexto: _descartar_cache(session))


# --- edição ---------------------------------------------------------------------------------------------------------------------------

def _direcionador_com_pool(session: Session, codigo: str) -> Direcionador:
    direcionador = session.query(Direcionador).filter_by(codigo=codigo).one_or_none()
    if direcionador is None:
        raise ValueError(f"Direcionador {codigo!r} não encontrado")
    if not any(p.direcionador.id == direcionador.id for p in pools(session)):
        raise ValueError(f"O Direcionador {codigo!r} não tem despesa fixa para ratear")
    return direcionador


def definir_rateio(session: Session, direcionador_codigo: str, percentuais: dict[str, Decimal]) -> None:
    """Grava o % de cada produto no pool do Direcionador (`percentuais` = {código do produto: fração}). Só produtos elegíveis, sem negativos, e o total
    precisa ser 100% — assim toda a despesa fixa do pool fica distribuída. Produto elegível que ficou de fora grava 0%."""
    exigir_aberta(session)
    direcionador = _direcionador_com_pool(session, direcionador_codigo)
    produtos = {p.codigo: p for p in session.query(Produto)}
    ids = set(elegiveis(session, direcionador))
    valores: dict[int, Decimal] = {}
    for codigo, valor in percentuais.items():
        produto = produtos.get(codigo)
        if produto is None:
            raise ValueError(f"Produto {codigo!r} não encontrado")
        if produto.id not in ids:
            raise ValueError(f"O produto {codigo!r} não participa do rateio do Direcionador {direcionador_codigo!r} (não usa a Atividade ou não tem volume)")
        valor = Decimal(valor)
        if not valor.is_finite() or valor < 0 or valor > 1:
            raise ValueError(f"O % do produto {codigo!r} deve ficar entre 0 e 100%")
        valores[produto.id] = valor
    soma = sum(valores.values(), Decimal(0))
    if abs(soma - 1) > TOLERANCIA:
        raise ValueError(f"O rateio precisa somar 100% (soma atual: {soma * 100:.4f}%) — toda a despesa fixa do Direcionador tem de ficar distribuída")
    gravar_fracoes(session, direcionador, valores)
    session.commit()


def gravar_fracoes(session: Session, direcionador: Direcionador, fracoes: dict[int, Decimal]) -> None:
    """Grava o % de cada produto elegível do Direcionador (sem validar nem travar: quem chama já conferiu; não faz commit)."""
    session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id).delete()
    for produto_id in sorted(elegiveis(session, direcionador)):
        percentual = Decimal(fracoes.get(produto_id, Decimal(0))).quantize(CASAS)
        session.add(RateioDespesa(direcionador_id=direcionador.id, produto_id=produto_id, percentual=percentual))


def tem_gravado(session: Session, direcionador: Direcionador) -> bool:
    return session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id).first() is not None


def sem_percentual(session: Session, direcionador: Direcionador) -> list[str]:
    """Códigos dos produtos que hoje entram no rateio do Direcionador mas não estavam no que o gestor gravou (ex.: produto novo na Atividade).
    Vazio quando não há nada gravado (vale a sugestão, que já cobre todos)."""
    gravados = {r.produto_id for r in session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id)}
    if not gravados:
        return []
    faltam = set(elegiveis(session, direcionador)) - gravados
    return sorted(p.codigo for p in session.query(Produto).filter(Produto.id.in_(faltam))) if faltam else []


def restaurar_sugestao(session: Session, direcionador_codigo: str) -> None:
    """Apaga o rateio gravado: o Direcionador volta a seguir a sugestão do critério."""
    exigir_aberta(session)
    direcionador = _direcionador_com_pool(session, direcionador_codigo)
    session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id).delete()
    session.commit()


def definir_criterio(session: Session, direcionador_codigo: str, criterio: str, peso_quantidade: Decimal | None = None) -> None:
    """Troca o critério da sugestão do Direcionador (e, na mistura, a parte da quantidade, 0–100%). O rateio gravado é descartado: a sugestão nova vale."""
    exigir_aberta(session)
    if criterio not in CRITERIOS_RATEIO:
        raise ValueError(f"Critério de rateio {criterio!r} inválido. Opções: {', '.join(CRITERIOS_RATEIO)}")
    if peso_quantidade is not None and (not Decimal(peso_quantidade).is_finite() or not Decimal(0) <= Decimal(peso_quantidade) <= 1):
        raise ValueError("A parte da quantidade na mistura deve ficar entre 0 e 100%")
    direcionador = _direcionador_com_pool(session, direcionador_codigo)
    grupo = session.query(RateioGrupo).filter_by(direcionador_id=direcionador.id).one_or_none()
    if grupo is None:
        grupo = RateioGrupo(direcionador_id=direcionador.id)
        session.add(grupo)
    grupo.criterio = criterio
    grupo.peso_quantidade = Decimal(peso_quantidade) if criterio == "mistura" and peso_quantidade is not None else None
    session.query(RateioDespesa).filter_by(direcionador_id=direcionador.id).delete()
    session.commit()
