from dataclasses import dataclass
from decimal import Decimal

from sqlalchemy.orm import Session

from custos_precificacao import arvore, grupos, historico, unidades
from custos_precificacao.custeio import RateioCenario, carregar_rateio_cenario
from custos_precificacao.models import (
    Ambiente,
    AmbienteGasto,
    Atividade,
    CenarioAmbienteDesligado,
    CenarioGrupoDesligado,
    CenarioMaquinaDesligado,
    CenarioPrecificacao,
    Direcionador,
    Funcionario,
    FuncionarioGasto,
    GrupoMaoDeObra,
    Insumo,
    Maquina,
    MaquinaGasto,
    PlanoDeContas,
    Produto,
    RoteiroAmbiente,
    RoteiroInsumo,
    RoteiroMaoDeObra,
    RoteiroMaquina,
)


@dataclass
class TipoRecursoRoteiro:
    roteiro_model: type
    recurso_model: type
    campo_fk: str
    chave_recurso: str


ROTEIRO_RECURSOS: dict[str, TipoRecursoRoteiro] = {
    "maquina": TipoRecursoRoteiro(RoteiroMaquina, Maquina, "maquina_id", "codigo"),
    "ambiente": TipoRecursoRoteiro(RoteiroAmbiente, Ambiente, "ambiente_id", "codigo"),
    "mao-de-obra": TipoRecursoRoteiro(RoteiroMaoDeObra, GrupoMaoDeObra, "grupo_id", "codigo"),  # alocada por grupo Depto-Cargo
}


# "Outros" é uma máquina de categoria 'outro': usa a mesma linha de roteiro (`RoteiroMaquina`), com tipo próprio nas telas e na importação.
TIPO_ROTEIRO_DE: dict[str, str] = {"outros": "maquina"}
CATEGORIA_DO_TIPO: dict[str, str] = {"maquina": "maquina", "outros": "outro"}


def _obter_por_codigo(session: Session, model: type, chave_coluna: str, valor: str):
    instancia = session.query(model).filter_by(**{chave_coluna: valor}).one_or_none()
    if instancia is None and chave_coluna == "codigo" and hasattr(model, "chave"):
        instancia = session.query(model).filter_by(chave=valor).one_or_none()  # código antigo (cadastros em árvore)
    if instancia is None:
        raise ValueError(f"{model.__name__} com {chave_coluna}={valor!r} não encontrado")
    return instancia


def resolver_direcionador(session: Session, direcionador_codigo: str, atividade_codigo: str | None = None) -> Direcionador:
    """Direcionador pelo qual um elemento (máquina, ambiente, mão de obra) é alocado: precisa ser de base `tempo` (o custo do
    elemento é por hora). Se a atividade também foi informada, ela tem de ser a do direcionador."""
    direcionador = _obter_por_codigo(session, Direcionador, "codigo", direcionador_codigo)
    if direcionador.base != "tempo":
        raise ValueError(
            f"O direcionador {direcionador.codigo!r} é de base {direcionador.base!r}; elementos de custo (por hora) usam direcionadores de base 'tempo'"
        )
    if atividade_codigo:
        atividade = _obter_por_codigo(session, Atividade, "codigo", atividade_codigo)
        if atividade.id != direcionador.atividade_id:
            raise ValueError(f"O direcionador {direcionador.codigo!r} não pertence à atividade {atividade_codigo!r}")
    return direcionador


def adicionar_roteiro_recurso(
    session: Session,
    tipo: str,
    produto_codigo: str,
    atividade_codigo: str | None,
    recurso_codigo: str,
    tempo_horas: str,
    atualizado_em: str | None = None,
    direcionador_codigo: str | None = None,
):
    """Linha de roteiro de máquina/ambiente/mão de obra. `atualizado_em` (mês, padrão o atual) é o mês a
    partir do qual a linha vale — o custeio de meses anteriores não a considera. Com `direcionador_codigo`, a
    atividade (etapa) é a do direcionador (`atividade_codigo` pode vir vazio ou tem de coincidir)."""
    categoria = CATEGORIA_DO_TIPO.get(tipo)
    tipo = TIPO_ROTEIRO_DE.get(tipo, tipo)
    if tipo not in ROTEIRO_RECURSOS:
        raise ValueError(f"Tipo de roteiro {tipo!r} inválido. Opções: {', '.join([*ROTEIRO_RECURSOS, *TIPO_ROTEIRO_DE])}")
    config = ROTEIRO_RECURSOS[tipo]

    produto = _obter_por_codigo(session, Produto, "codigo", produto_codigo)
    if produto.tipo == "revenda":
        raise ValueError(f"O produto de revenda {produto.codigo!r} não recebe elementos de custo")
    direcionador = resolver_direcionador(session, direcionador_codigo, atividade_codigo) if direcionador_codigo else None
    if direcionador is not None:
        atividade = session.get(Atividade, direcionador.atividade_id)
    elif atividade_codigo:
        atividade = _obter_por_codigo(session, Atividade, "codigo", atividade_codigo)
    else:
        raise ValueError("Informe a atividade ou o direcionador")
    if tipo == "mao-de-obra":  # o código do grupo; o código FOPAG de um colaborador (jeito antigo) vale como o grupo dele
        recurso = grupos.grupo_do_codigo(session, recurso_codigo)
        if recurso is None:
            raise ValueError(f"GrupoMaoDeObra com codigo={recurso_codigo!r} não encontrado")
    else:
        recurso = _obter_por_codigo(session, config.recurso_model, config.chave_recurso, recurso_codigo)
    arvore.exigir_lancavel(recurso)
    if categoria is not None and recurso.categoria != categoria:
        raise ValueError(
            f"{recurso.codigo!r} é um elemento {'Outros' if recurso.categoria == 'outro' else 'Máquina'}; use o tipo "
            f"{'outros' if recurso.categoria == 'outro' else 'maquina'}"
        )

    linha = config.roteiro_model(
        produto_id=produto.id,
        atividade_id=atividade.id,
        direcionador_id=direcionador.id if direcionador else None,
        tempo_horas=Decimal(tempo_horas),
        atualizado_em=historico.normalizar_mes(atualizado_em),
        **{config.campo_fk: recurso.id},
    )
    session.add(linha)
    session.commit()
    return linha


def _validar_unidade_e_perda(session: Session, item, unidade: str, perda_pct: str | None) -> None:
    """A unidade da linha tem de ser convertível para a do item (cadastre a conversão antes) e a perda fica entre 0 e 100%."""
    unidades.fator_de_perda(Decimal(perda_pct or "0"))
    unidades.converter(session, item, Decimal(1), unidade, item.unidade)


def adicionar_roteiro_insumo(
    session: Session,
    produto_codigo: str,
    atividade_codigo: str,
    quantidade_kg: str,
    *,
    insumo_codigo: str | None = None,
    insumo_produto_codigo: str | None = None,
    atualizado_em: str | None = None,
    unidade: str | None = None,
    perda_pct: str = "0",
):
    if (insumo_codigo is None) == (insumo_produto_codigo is None):
        raise ValueError("Informe exatamente um entre --insumo e --insumo-produto")

    produto = _obter_por_codigo(session, Produto, "codigo", produto_codigo)
    atividade = _obter_por_codigo(session, Atividade, "codigo", atividade_codigo)

    insumo_id = None
    insumo_produto_id = None
    if insumo_codigo is not None:
        item = _obter_por_codigo(session, Insumo, "codigo", insumo_codigo)
        insumo_id = item.id
    else:
        item = _obter_por_codigo(session, Produto, "codigo", insumo_produto_codigo)
        insumo_produto_id = item.id
    unidade_linha = unidades.normalizar(unidade)
    _validar_unidade_e_perda(session, item, unidade_linha, perda_pct)

    linha = RoteiroInsumo(
        produto_id=produto.id,
        atividade_id=atividade.id,
        insumo_id=insumo_id,
        insumo_produto_id=insumo_produto_id,
        quantidade_kg=Decimal(quantidade_kg),
        unidade=unidade_linha,
        perda_pct=Decimal(perda_pct or "0"),
        atualizado_em=historico.normalizar_mes(atualizado_em),
    )
    session.add(linha)
    session.commit()
    return linha


def listar_roteiro(session: Session, produto_codigo: str):
    produto = _obter_por_codigo(session, Produto, "codigo", produto_codigo)
    return {
        "maquina": list(produto.roteiro_maquina),
        "ambiente": list(produto.roteiro_ambiente),
        "mao-de-obra": list(produto.roteiro_mao_de_obra),
        "insumo": list(produto.roteiro_insumo),
    }


ROTEIRO_MODELS: dict[str, type] = {
    "maquina": RoteiroMaquina,
    "ambiente": RoteiroAmbiente,
    "mao-de-obra": RoteiroMaoDeObra,
    "insumo": RoteiroInsumo,
}


def _linha_roteiro(session: Session, tipo: str, linha_id: int):
    if tipo not in ROTEIRO_MODELS:
        raise ValueError(f"Tipo de roteiro {tipo!r} inválido. Opções: {', '.join(ROTEIRO_MODELS)}")
    model = ROTEIRO_MODELS[tipo]
    linha = session.get(model, linha_id)
    if linha is None:
        raise ValueError(f"Linha de {model.__name__} com id={linha_id} não encontrada")
    return linha


def remover_linha_roteiro(session: Session, tipo: str, linha_id: int, em: str | None = None) -> bool:
    """Remove a linha a partir do mês `em` (padrão o atual). A linha antiga fica no histórico e segue
    valendo para os meses anteriores; devolve False se ela nasceu no próprio mês (só apagada)."""
    tipo = TIPO_ROTEIRO_DE.get(tipo, tipo)
    return historico.remover_com_historico(session, _linha_roteiro(session, tipo, linha_id), em)


def alterar_linha_roteiro(
    session: Session, tipo: str, linha_id: int, quantidade: str, atualizado_em: str | None = None,
    unidade: str | None = None, perda_pct: str | None = None, direcionador_codigo: str | None = None,
):
    """Altera o tempo (horas) ou a quantidade (na unidade da linha, no insumo) de uma linha de roteiro, com histórico.
    No insumo também mudam a unidade e a perda % da linha; nos elementos (máquina, ambiente, mão de obra), o direcionador
    (e com ele a etapa)."""
    tipo = TIPO_ROTEIRO_DE.get(tipo, tipo)
    linha = _linha_roteiro(session, tipo, linha_id)
    campo = "quantidade_kg" if tipo == "insumo" else "tempo_horas"
    novos = {campo: Decimal(quantidade)}
    if tipo == "insumo":
        item = session.get(Insumo, linha.insumo_id) if linha.insumo_id is not None else session.get(Produto, linha.insumo_produto_id)
        nova_unidade = unidades.normalizar(unidade) if unidade else unidades.normalizar(linha.unidade)
        nova_perda = perda_pct if perda_pct is not None else str(linha.perda_pct or 0)
        _validar_unidade_e_perda(session, item, nova_unidade, nova_perda)
        novos["unidade"] = nova_unidade
        novos["perda_pct"] = Decimal(nova_perda)
    elif direcionador_codigo:
        direcionador = resolver_direcionador(session, direcionador_codigo)
        novos["direcionador_id"] = direcionador.id
        novos["atividade_id"] = direcionador.atividade_id
    historico.atualizar_com_historico(session, linha, novos, atualizado_em)
    return linha


@dataclass
class TipoRecursoCenario:
    desligado_model: type
    recurso_model: type
    campo_fk: str
    chave_recurso: str


CENARIO_RECURSOS: dict[str, TipoRecursoCenario] = {
    "ambiente": TipoRecursoCenario(CenarioAmbienteDesligado, Ambiente, "ambiente_id", "codigo"),
    "maquina": TipoRecursoCenario(CenarioMaquinaDesligado, Maquina, "maquina_id", "codigo"),
    "grupo": TipoRecursoCenario(CenarioGrupoDesligado, GrupoMaoDeObra, "grupo_id", "codigo"),
}


def _linha_desligado(session: Session, config: TipoRecursoCenario, cenario_id: int, recurso_id: int):
    return (
        session.query(config.desligado_model)
        .filter_by(cenario_id=cenario_id, **{config.campo_fk: recurso_id})
        .one_or_none()
    )


def desligar_recurso(session: Session, cenario_codigo: str, tipo: str, recurso_codigo: str):
    if tipo not in CENARIO_RECURSOS:
        raise ValueError(f"Tipo de recurso {tipo!r} inválido. Opções: {', '.join(CENARIO_RECURSOS)}")
    config = CENARIO_RECURSOS[tipo]

    cenario = _obter_por_codigo(session, CenarioPrecificacao, "codigo", cenario_codigo)
    recurso = _obter_por_codigo(session, config.recurso_model, config.chave_recurso, recurso_codigo)

    if _linha_desligado(session, config, cenario.id, recurso.id) is not None:
        return  # ja desligado, idempotente

    linha = config.desligado_model(cenario_id=cenario.id, **{config.campo_fk: recurso.id})
    session.add(linha)
    session.commit()


def ligar_recurso(session: Session, cenario_codigo: str, tipo: str, recurso_codigo: str) -> None:
    if tipo not in CENARIO_RECURSOS:
        raise ValueError(f"Tipo de recurso {tipo!r} inválido. Opções: {', '.join(CENARIO_RECURSOS)}")
    config = CENARIO_RECURSOS[tipo]

    cenario = _obter_por_codigo(session, CenarioPrecificacao, "codigo", cenario_codigo)
    recurso = _obter_por_codigo(session, config.recurso_model, config.chave_recurso, recurso_codigo)

    linha = _linha_desligado(session, config, cenario.id, recurso.id)
    if linha is None:
        return  # ja ligado, idempotente

    session.delete(linha)
    session.commit()


def status_rateio_cenario(session: Session, cenario_codigo: str) -> RateioCenario:
    cenario = _obter_por_codigo(session, CenarioPrecificacao, "codigo", cenario_codigo)
    return carregar_rateio_cenario(session, cenario)


@dataclass
class TipoRecursoGasto:
    gasto_model: type
    recurso_model: type
    campo_fk: str
    chave_recurso: str


GASTO_RECURSOS: dict[str, TipoRecursoGasto] = {
    "funcionario": TipoRecursoGasto(FuncionarioGasto, Funcionario, "funcionario_id", "codigo_fopag"),
    "maquina": TipoRecursoGasto(MaquinaGasto, Maquina, "maquina_id", "codigo"),
    "ambiente": TipoRecursoGasto(AmbienteGasto, Ambiente, "ambiente_id", "codigo"),
}


def adicionar_gasto(
    session: Session,
    tipo: str,
    recurso_codigo: str,
    plano_de_contas_codigo: str,
    desembolso: str,
    retencao: str = "0",
    atualizado_em: str | None = None,
):
    """Linha de gasto de um recurso. `atualizado_em` (mês, padrão o atual) é o mês a partir do qual ela
    vale: o custo/hora e o custeio de meses anteriores não a consideram."""
    if tipo not in GASTO_RECURSOS:
        raise ValueError(f"Tipo de recurso {tipo!r} inválido. Opções: {', '.join(GASTO_RECURSOS)}")
    config = GASTO_RECURSOS[tipo]

    recurso = _obter_por_codigo(session, config.recurso_model, config.chave_recurso, recurso_codigo)
    plano_de_contas = _obter_por_codigo(session, PlanoDeContas, "codigo", plano_de_contas_codigo)
    arvore.exigir_lancavel(recurso)
    arvore.exigir_lancavel(plano_de_contas)

    linha = config.gasto_model(
        plano_de_contas_id=plano_de_contas.id,
        desembolso=Decimal(desembolso),
        retencao=Decimal(retencao),
        atualizado_em=historico.normalizar_mes(atualizado_em),
        **{config.campo_fk: recurso.id},
    )
    session.add(linha)
    session.commit()
    return linha


def listar_gastos(session: Session, tipo: str, recurso_codigo: str):
    if tipo not in GASTO_RECURSOS:
        raise ValueError(f"Tipo de recurso {tipo!r} inválido. Opções: {', '.join(GASTO_RECURSOS)}")
    config = GASTO_RECURSOS[tipo]
    recurso = _obter_por_codigo(session, config.recurso_model, config.chave_recurso, recurso_codigo)
    return list(recurso.gastos)


def _gasto(session: Session, tipo: str, gasto_id: int):
    if tipo not in GASTO_RECURSOS:
        raise ValueError(f"Tipo de recurso {tipo!r} inválido. Opções: {', '.join(GASTO_RECURSOS)}")
    model = GASTO_RECURSOS[tipo].gasto_model
    linha = session.get(model, gasto_id)
    if linha is None:
        raise ValueError(f"Linha de {model.__name__} com id={gasto_id} não encontrada")
    return linha


_NAO_INFORMADO = object()


def alterar_gasto(
    session: Session,
    tipo: str,
    gasto_id: int,
    *,
    desembolso: str | None = None,
    retencao: str | None = None,
    plano_de_contas_codigo: str | None = None,
    atualizado_em: str | None = None,
):
    """Altera uma linha de gasto com histórico: o valor antigo fica registrado até o mês `atualizado_em`
    (padrão o atual)."""
    linha = _gasto(session, tipo, gasto_id)
    novos: dict = {}
    if desembolso is not None:
        novos["desembolso"] = Decimal(desembolso)
    if retencao is not None:
        novos["retencao"] = Decimal(retencao)
    if plano_de_contas_codigo is not None:
        conta = _obter_por_codigo(session, PlanoDeContas, "codigo", plano_de_contas_codigo)
        arvore.exigir_lancavel(conta)
        novos["plano_de_contas_id"] = conta.id
    historico.atualizar_com_historico(session, linha, novos, atualizado_em)
    return linha


def remover_gasto(session: Session, tipo: str, gasto_id: int, em: str | None = None) -> bool:
    """Encerra uma linha de gasto a partir do mês `em` (padrão o atual): ela some do custo desse mês em
    diante, mas fica no histórico e segue valendo nos meses anteriores. Devolve False se a linha nasceu
    no próprio mês (nesse caso é só apagada, como correção)."""
    return historico.remover_com_historico(session, _gasto(session, tipo, gasto_id), em)
