"""Histórico das entradas de custo (§1.1-f/§9): versões "Atualizado em (mês)".

Padrão **atual + histórico**: a linha da própria tabela guarda a versão mais recente, com
`atualizado_em` (o mês a partir do qual ela vale). Quando um valor muda para um mês posterior, a
versão antiga vai para `HistoricoVersao` (com o período em que valeu) e a linha recebe o novo valor.
Assim, tudo que lê "o valor de hoje" continua funcionando, e `valores_em`/`linhas_em` respondem
"como era em tal mês".

Dois tipos de entrada versionada:

- **atributo** (preço do Insumo, horas, rendimento): a linha continua existindo; mês anterior à
  primeira versão usa a primeira (o que sabemos vale para trás).
- **linha** (Gasto de recurso, linha de roteiro): pode aparecer e sumir ao longo do tempo; uma linha
  criada em outubro **não** existe em setembro, e uma linha removida some a partir do mês da remoção.

Meses são sempre o dia 1. Mudar no **mesmo mês** da versão atual é uma *correção* (sobrescreve, sem
histórico); mudar para um mês **anterior** ao da versão atual é recusado.
"""

from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from typing import Any

from sqlalchemy import Date, Numeric, event, inspect
from sqlalchemy.orm import Session

from custos_precificacao.models import (
    Ambiente,
    AmbienteGasto,
    Funcionario,
    FuncionarioGasto,
    HistoricoVersao,
    Insumo,
    Maquina,
    MaquinaGasto,
    Produto,
    RoteiroAmbiente,
    RoteiroInsumo,
    RoteiroMaoDeObra,
    RoteiroMaquina,
)


class ErroDeHistorico(ValueError):
    """Regra do histórico violada (mês inválido, versão mais recente já existente…): mensagem pronta
    pra mostrar ao usuário."""


@dataclass(frozen=True)
class Versionada:
    campos: tuple[str, ...]  # campos versionados (e as chaves estrangeiras da linha, no caso de linhas)
    pai: str | None = None  # None = atributo; senão, FK da linha para o dono (ex.: 'ambiente_id')
    dono: type | None = None  # modelo do dono (só para linhas)


_GASTO = ("plano_de_contas_id", "desembolso", "retencao")

TRACKED: dict[type, Versionada] = {
    # linhas
    AmbienteGasto: Versionada(("ambiente_id", *_GASTO), pai="ambiente_id", dono=Ambiente),
    MaquinaGasto: Versionada(("maquina_id", *_GASTO), pai="maquina_id", dono=Maquina),
    FuncionarioGasto: Versionada(("funcionario_id", *_GASTO), pai="funcionario_id", dono=Funcionario),
    RoteiroMaquina: Versionada(
        ("produto_id", "atividade_id", "ordem", "maquina_id", "tempo_horas", "direcionador_id"), pai="produto_id", dono=Produto
    ),
    RoteiroAmbiente: Versionada(
        ("produto_id", "atividade_id", "ordem", "ambiente_id", "tempo_horas", "direcionador_id"), pai="produto_id", dono=Produto
    ),
    RoteiroMaoDeObra: Versionada(
        ("produto_id", "atividade_id", "ordem", "grupo_id", "tempo_horas", "direcionador_id"), pai="produto_id", dono=Produto
    ),
    RoteiroInsumo: Versionada(
        ("produto_id", "atividade_id", "ordem", "insumo_id", "insumo_produto_id", "quantidade_kg", "unidade", "perda_pct"),
        pai="produto_id",
        dono=Produto,
    ),
    # atributos
    Insumo: Versionada(("valor_compra_unitario", "frete", "ipi_pct", "icms_pct")),
    Funcionario: Versionada(("horas_contratadas_mensais", "absenteismo_pct")),
    Maquina: Versionada(("horas_uteis_mensais",)),
    Ambiente: Versionada(("horas_uteis_mensais",)),
    Produto: Versionada(("rendimento_kg", "rendimento_un", "custo_compra")),
}


def eh_versionada(modelo: type) -> bool:
    return modelo in TRACKED


# --- meses -----------------------------------------------------------------------------------

def mes_atual() -> date:
    return date.today().replace(day=1)


def normalizar_mes(valor: date | str | None) -> date:
    """Dia 1 do mês. Aceita date, 'AAAA-MM', 'AAAA-MM-DD' ou None (mês atual)."""
    if valor is None or valor == "":
        return mes_atual()
    if isinstance(valor, datetime):
        return valor.date().replace(day=1)
    if isinstance(valor, date):
        return valor.replace(day=1)
    texto = str(valor).strip()
    try:
        if len(texto) == 7:
            return date.fromisoformat(f"{texto}-01")
        return date.fromisoformat(texto).replace(day=1)
    except ValueError as exc:
        raise ErroDeHistorico(f"Mês inválido {valor!r}; use AAAA-MM") from exc


def rotulo_mes(valor: date) -> str:
    return f"{valor.month:02d}/{valor.year}"


# --- (de)serialização dos valores guardados ---------------------------------------------------

def _serializar(valor: Any) -> Any:
    if isinstance(valor, Decimal):
        return str(valor)
    if isinstance(valor, (date, datetime)):
        return valor.isoformat()
    return valor


def _desserializar(modelo: type, campo: str, valor: Any) -> Any:
    if valor is None:
        return None
    tipo = inspect(modelo).columns[campo].type
    if isinstance(tipo, Numeric):
        return Decimal(str(valor))
    if isinstance(tipo, Date):
        return date.fromisoformat(valor)
    return valor


def _valores(instancia: Any, cfg: Versionada) -> dict[str, Any]:
    return {campo: getattr(instancia, campo) for campo in cfg.campos}


def _json(instancia: Any, cfg: Versionada) -> dict[str, Any]:
    return {campo: _serializar(valor) for campo, valor in _valores(instancia, cfg).items()}


def _config(instancia_ou_modelo: Any) -> Versionada:
    modelo = instancia_ou_modelo if isinstance(instancia_ou_modelo, type) else type(instancia_ou_modelo)
    if modelo not in TRACKED:
        raise ErroDeHistorico(f"{modelo.__name__} não tem entradas versionadas")
    return TRACKED[modelo]


# --- autoria ----------------------------------------------------------------------------------

def usuario_da_sessao(session: Session) -> str | None:
    """E-mail do usuário que está operando (a API o põe em `session.info`; o CLI usa 'terminal')."""
    return session.info.get("usuario")


def _autoria_da_versao(session: Session, instancia: Any) -> dict[str, Any]:
    """Autoria da versão que vai para o histórico: quem/quando a digitou (vem da linha) e quem a substitui."""
    return {
        "autor": instancia.digitado_por,
        "digitado_em": instancia.digitado_em,
        "substituido_por": usuario_da_sessao(session),
    }


@event.listens_for(Session, "before_flush")
def _carimbar_autoria(session: Session, flush_context: Any, instances: Any) -> None:
    """Carimba `digitado_por/em` nas linhas versionadas **criadas** ou cujo campo versionado mudou
    (inclusive correção no mesmo mês). Campos não versionados (nome…) não mexem na autoria."""
    agora = datetime.utcnow()
    usuario = usuario_da_sessao(session)
    for instancia in session.new:
        if type(instancia) in TRACKED:
            instancia.digitado_por, instancia.digitado_em = usuario, agora
    for instancia in session.dirty:
        cfg = TRACKED.get(type(instancia))
        if cfg is None:
            continue
        estado = inspect(instancia)
        if any(estado.attrs[c].history.has_changes() for c in cfg.campos):
            instancia.digitado_por, instancia.digitado_em = usuario, agora


# --- escrita ----------------------------------------------------------------------------------

def atualizar_com_historico(
    session: Session,
    instancia: Any,
    novos: dict[str, Any],
    atualizado_em: date | str | None = None,
    *,
    commit: bool = True,
) -> bool:
    """Atualiza `instancia` com `novos` (já convertidos para o tipo da coluna).

    Campos não versionados (nome, classificação…) são gravados direto, sem histórico. Se algum
    campo versionado muda de valor: no mesmo mês da versão atual é correção (sobrescreve); num mês
    posterior a versão antiga vai para o histórico e a linha passa a valer desse mês em diante; num
    mês anterior ao da versão atual levanta ValueError. Devolve True se uma versão nova foi criada
    ou corrigida (False se nada versionado mudou).
    """
    cfg = _config(instancia)
    for campo, valor in novos.items():
        if campo not in cfg.campos:
            setattr(instancia, campo, valor)
    mudancas = {c: v for c, v in novos.items() if c in cfg.campos and getattr(instancia, c) != v}

    if not mudancas:
        if commit:
            session.commit()
        return False

    mes = normalizar_mes(atualizado_em)
    atual = instancia.atualizado_em
    if mes < atual:
        raise ErroDeHistorico(
            f"Já existe uma versão mais recente (atualizada em {rotulo_mes(atual)}); "
            f"informe um mês igual ou posterior a {rotulo_mes(atual)}"
        )
    if mes > atual:
        session.add(HistoricoVersao(
            entidade=type(instancia).__tablename__,
            registro_id=instancia.id,
            atualizado_em=atual,
            substituido_em=mes,
            acao="alterado",
            valores=_json(instancia, cfg),
            **_autoria_da_versao(session, instancia),
        ))
        instancia.atualizado_em = mes
    for campo, valor in mudancas.items():
        setattr(instancia, campo, valor)
    if commit:
        session.commit()
    return True


def remover_com_historico(
    session: Session, instancia: Any, em: date | str | None = None, *, commit: bool = True
) -> bool:
    """Remove uma **linha** (Gasto, linha de roteiro) a partir do mês `em`. Devolve True se a linha
    foi arquivada (existia antes de `em`) e False se foi só apagada (nasceu no próprio mês: correção).

    As versões anteriores da linha, se houver, ficam no histórico e seguem valendo até `em`.
    """
    cfg = _config(instancia)
    if cfg.pai is None:
        raise ErroDeHistorico(f"{type(instancia).__name__} é um atributo, não uma linha — não se remove com histórico")
    mes = normalizar_mes(em)
    atual = instancia.atualizado_em
    if mes < atual:
        raise ErroDeHistorico(
            f"Já existe uma versão mais recente (atualizada em {rotulo_mes(atual)}); "
            f"remova a partir de {rotulo_mes(atual)} ou depois"
        )
    arquivada = mes > atual
    if arquivada:
        session.add(HistoricoVersao(
            entidade=type(instancia).__tablename__,
            registro_id=instancia.id,
            atualizado_em=atual,
            substituido_em=mes,
            acao="removido",
            valores=_json(instancia, cfg),
            **_autoria_da_versao(session, instancia),
        ))
    session.delete(instancia)
    if commit:
        session.commit()
    return arquivada


def apagar_historico(session: Session, instancia: Any) -> None:
    """Limpa o histórico de uma entidade que está sendo excluída por inteiro (e o das linhas que ela
    tinha como dono), pra não deixar versões órfãs."""
    modelo = type(instancia)
    if modelo in TRACKED:
        session.query(HistoricoVersao).filter_by(entidade=modelo.__tablename__, registro_id=instancia.id).delete()
    for linha_modelo, cfg in TRACKED.items():
        if cfg.dono is modelo:
            for registro in session.query(HistoricoVersao).filter_by(entidade=linha_modelo.__tablename__):
                if registro.valores.get(cfg.pai) == instancia.id:
                    session.delete(registro)


# --- leitura "como era em tal mês" -------------------------------------------------------------

Versao = tuple[date, date | None, dict[str, Any]]  # (atualizado_em, substituido_em, valores)


class IndiceHistorico:
    """Cache do histórico arquivado por entidade (uma consulta por tabela, não uma por registro)."""

    def __init__(self, session: Session):
        self.session = session
        self._por_entidade: dict[str, dict[int, list[HistoricoVersao]]] = {}

    def arquivadas(self, modelo: type) -> dict[int, list[HistoricoVersao]]:
        tabela = modelo.__tablename__
        if tabela not in self._por_entidade:
            agrupado: dict[int, list[HistoricoVersao]] = {}
            consulta = self.session.query(HistoricoVersao).filter_by(entidade=tabela).order_by(HistoricoVersao.atualizado_em)
            for registro in consulta:
                agrupado.setdefault(registro.registro_id, []).append(registro)
            self._por_entidade[tabela] = agrupado
        return self._por_entidade[tabela]


def _versoes_arquivadas(modelo: type, registros: list[HistoricoVersao]) -> list[Versao]:
    return [
        (r.atualizado_em, r.substituido_em, {c: _desserializar(modelo, c, v) for c, v in r.valores.items()})
        for r in registros
    ]


def versoes(session: Session, instancia: Any, indice: IndiceHistorico | None = None) -> list[Versao]:
    """Todas as versões de um registro, da mais antiga à atual (a atual tem `substituido_em` None)."""
    cfg = _config(instancia)
    modelo = type(instancia)
    indice = indice or IndiceHistorico(session)
    lista = _versoes_arquivadas(modelo, indice.arquivadas(modelo).get(instancia.id, []))
    lista.append((instancia.atualizado_em, None, _valores(instancia, cfg)))
    return lista


def versoes_com_autoria(session: Session, instancia: Any) -> list[SimpleNamespace]:
    """Como `versoes`, mas cada item traz também a autoria: `inicio`, `fim`, `valores`, `autor`/`digitado_em`
    (quem/quando digitou a versão) e `substituido_por` (quem a substituiu; None na versão atual)."""
    cfg = _config(instancia)
    modelo = type(instancia)
    registros = (
        session.query(HistoricoVersao)
        .filter_by(entidade=modelo.__tablename__, registro_id=instancia.id)
        .order_by(HistoricoVersao.atualizado_em)
        .all()
    )
    lista = [
        SimpleNamespace(
            inicio=r.atualizado_em, fim=r.substituido_em,
            valores={c: _desserializar(modelo, c, v) for c, v in r.valores.items()},
            autor=r.autor, digitado_em=r.digitado_em, substituido_por=r.substituido_por,
        )
        for r in registros
    ]
    lista.append(SimpleNamespace(
        inicio=instancia.atualizado_em, fim=None, valores=_valores(instancia, cfg),
        autor=instancia.digitado_por, digitado_em=instancia.digitado_em, substituido_por=None,
    ))
    return lista


def _cobre(versao: Versao, mes: date) -> bool:
    inicio, fim, _ = versao
    return inicio <= mes and (fim is None or mes < fim)


def valores_em(
    session: Session, instancia: Any, competencia: date | str, indice: IndiceHistorico | None = None
) -> dict[str, Any]:
    """Valores dos campos versionados de um **atributo** no mês `competencia`. Mês anterior à primeira
    versão usa a primeira."""
    mes = normalizar_mes(competencia)
    lista = versoes(session, instancia, indice)
    for versao in lista:
        if _cobre(versao, mes):
            return versao[2]
    return lista[0][2]


def linhas_em(
    session: Session, modelo: type, dono_id: int, competencia: date | str, indice: IndiceHistorico | None = None
) -> list[SimpleNamespace]:
    """Linhas (Gasto, roteiro) de um dono que existiam no mês `competencia`, com os valores da época.
    Cada item traz `id` e os campos versionados como atributos."""
    cfg = _config(modelo)
    if cfg.pai is None:
        raise ErroDeHistorico(f"{modelo.__name__} é um atributo, não uma linha")
    mes = normalizar_mes(competencia)
    indice = indice or IndiceHistorico(session)
    arquivadas = indice.arquivadas(modelo)

    candidatas: dict[int, list[Versao]] = {}
    for registro_id, registros in arquivadas.items():
        if registros[0].valores.get(cfg.pai) == dono_id:
            candidatas[registro_id] = _versoes_arquivadas(modelo, registros)
    for linha in session.query(modelo).filter(getattr(modelo, cfg.pai) == dono_id):
        candidatas.setdefault(linha.id, []).append((linha.atualizado_em, None, _valores(linha, cfg)))

    resultado = []
    for registro_id, lista in sorted(candidatas.items()):
        for versao in lista:
            if _cobre(versao, mes):
                resultado.append(SimpleNamespace(id=registro_id, atualizado_em=versao[0], **versao[2]))
                break
    return resultado


def historico_de_linhas(session: Session, modelo: type, dono_id: int) -> list[HistoricoVersao]:
    """Versões arquivadas (alteradas/removidas) das linhas de um dono, da mais recente para a mais antiga."""
    cfg = _config(modelo)
    registros = session.query(HistoricoVersao).filter_by(entidade=modelo.__tablename__)
    return sorted(
        (r for r in registros if r.valores.get(cfg.pai) == dono_id),
        key=lambda r: (r.substituido_em, r.id),
        reverse=True,
    )
