# app/controllers/admin/edicao_lancamentos_controller.rb
#
# Correção de lançamentos do SimpliRoute pelo ADM. Busca a entrega pela NF (na
# base de rastreio local), resolve o id numérico na API e grava a alteração
# DIRETO na API do SimpliRoute — registrando tudo no AuditoriaLog.
#
# ⚠️ A base de rastreio local (Entrega) é só leitura e sincroniza depois: a
# alteração só reflete no painel na próxima sincronização.
class Admin::EdicaoLancamentosController < ApplicationController
  before_action :garantir_configurado

  # Campos que o ADM pode editar (fase 1). checkout_observation = UUID do motivo.
  CAMPOS_EDITAVEIS = %w[
    status checkout_observation checkout_comment notes
    checkout_time checkout_latitude checkout_longitude
  ].freeze

  STATUS_VALIDOS = %w[pending partial completed failed canceled].freeze

  def show
    authorize :edicao_lancamento
    @motivos = motivos_seguros
  end

  # GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com TODOS os lançamentos
  # daquela NF.
  #
  # ⚠️ Uma NF pode ter mais de um lançamento: quando o plano é duplicado no
  # SimpliRoute nasce uma visita nova (outro tracking_id) com a mesma NF, e a
  # antiga — a que tem o motorista/checkout — continua existindo. Por isso a
  # busca devolve a LISTA e o ADM escolhe qual editar; assumir "1 NF = 1
  # lançamento" escondia justamente o lançamento antigo.
  #
  # Com um único lançamento já vem o card montado, para não custar um segundo
  # round-trip no caso comum.
  #
  # Aceita ?de=&ate= para varrer um período (a API só filtra por UM dia, então
  # varremos dia a dia). Sem período, usa as datas que o espelho conhece.
  def buscar
    authorize :edicao_lancamento

    nf = params[:nf].to_s.strip
    return render_erro('Informe o número da NF.') if nf.blank?

    entregas = Entrega.por_nf(nf).to_a
    datas    = datas_de_busca(entregas)
    return render_erro(sem_datas_msg(nf, entregas)) if datas.empty?

    falhas      = []
    ocorrencias = ocorrencias_da_nf(nf, entregas, datas, falhas)
    unica       = ocorrencias.first if ocorrencias.one? && ocorrencias.first[:id].present?

    render json: {
      ok:          true,
      ocorrencias: ocorrencias,
      visita:      (unica && payload_seguro(unica, entregas, falhas)),
      periodo:     { de: datas.first, ate: datas.last, dias: datas.size },
      # Dias que a API não respondeu. Vão para a tela: uma busca que devolve
      # menos do que existe TEM que dizer que devolveu menos.
      falhas:      falhas,
      motivos:     motivos_seguros
    }
  rescue SimpliRoute::NotFound => e
    render_erro(e.message)
  rescue SimpliRoute::Error => e
    render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
  end

  # GET /admin/edicao_lancamento/carregar?visit_id=871407488&tracking_id=SR...
  # Monta o card de UM lançamento escolhido na lista de ocorrências.
  def carregar
    authorize :edicao_lancamento

    id = params[:visit_id].to_s.strip
    return render_erro('Lançamento não identificado.') if id.blank?

    entrega = Entrega.find_by(tracking_id: params[:tracking_id]) if params[:tracking_id].present?

    render json: { ok: true, visita: payload_visita(id, entrega), motivos: motivos_seguros }
  rescue SimpliRoute::NotFound => e
    render_erro(e.message)
  rescue SimpliRoute::Error => e
    render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
  end

  # PATCH /admin/edicao_lancamento/atualizar
  # HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
  # (click-to-edit) — o fetch manda visit_id + só o campo editado.
  def atualizar
    authorize :edicao_lancamento

    id = params[:visit_id].to_s.strip
    return responder_erro('Visita não identificada.') if id.blank?

    anterior = client.visita(id)
    attrs    = mudancas(anterior)

    # Confirmar um valor idêntico não é erro no inline edit — só não há o que fazer.
    if attrs.empty?
      return respond_to do |format|
        format.json { render json: { ok: true, campos: [], visita: {} } }
        format.html { redirect_to admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.' }
      end
    end
    if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
      return responder_erro('Status inválido.')
    end

    atualizada = client.atualizar_visita(id, attrs)

    AuditoriaLog.registrar(
      user:             current_user,
      acao:             'editar',
      entidade:         'SimpliRoute::Visita',
      entidade_id:      id,
      dados_anteriores: anterior.slice(*attrs.keys),
      dados_novos:      atualizada.slice(*attrs.keys),
      request:          request
    )

    # Mudança na operação feita POR DENTRO do sistema. A maioria das mudanças
    # vem de fora e é pega pelo DetectarMudancasOperacaoJob; esta chega na hora
    # e sabe exatamente qual NF e o que mudou.
    Notificacao::Gatilhos.operacao_alterada(
      operacao:    anterior['title'].presence || 'SimpliRoute',
      o_que_mudou: descrever_mudanca(anterior, atualizada, attrs.keys),
      nf:          anterior['reference']
    )

    respond_to do |format|
      format.json do
        render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
      end
      format.html do
        redirect_to admin_edicao_lancamento_path,
                    notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
                            'O painel refletirá na próxima sincronização.'
      end
    end
  rescue SimpliRoute::Error => e
    responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
  end

  # GET /admin/edicao_lancamento/historico?visit_id=123 → JSON com as DUAS fontes:
  # o histórico da API do SimpliRoute (best-effort, costuma vir vazio) e a nossa
  # trilha do AuditoriaLog (quem editou, quando, de→para).
  def historico
    authorize :edicao_lancamento

    id = params[:visit_id].to_s.strip
    interno = AuditoriaLog.por_entidade('SimpliRoute::Visita')
                          .where(entidade_id: id)
                          .includes(:user).recentes.limit(50)
                          .map do |log|
      {
        usuario: log.user&.nome_display || '—',
        acao:    log.acao,
        quando:  log.created_at.in_time_zone.strftime('%d/%m/%Y %H:%M'),
        de:      log.dados_anteriores,
        para:    log.dados_novos
      }
    end

    render json: { ok: true, api: client.historico(id), interno: interno }
  end

  private

  # "status: pending → completed; observação: … → ÓBITO" — o texto que vai para
  # a variável {{o_que_mudou}} do editor de blocos.
  def descrever_mudanca(anterior, atualizada, campos)
    Array(campos).filter_map do |campo|
      de   = anterior[campo].to_s.strip
      para = atualizada[campo].to_s.strip
      next if de == para

      "#{campo}: #{de.presence || '(vazio)'} → #{para.presence || '(vazio)'}"
    end.join('; ').presence || 'lançamento atualizado'
  end

  def client
    @client ||= SimpliRoute::Client.new
  end

  # Máximo de dias varridos numa busca por período. A API só filtra por UM dia,
  # então cada dia é uma chamada — com `search` cada uma custa ~0,6 s e elas vão
  # em lotes paralelos, o que torna dois meses viável; o teto existe para a tela
  # não virar uma varredura sem fim.
  MAX_DIAS_VARREDURA = 62

  # Quais dias consultar na API:
  #   • com ?de=/?ate= — o período pedido (o SimpliRoute duplica plano para
  #     outra data, e aí o espelho não tem como saber qual é);
  #   • sem período — as datas que o espelho conhece (barato: 1 ou 2 chamadas).
  def datas_de_busca(entregas)
    de  = data_param(:de)
    ate = data_param(:ate)

    if de || ate
      inicio, fim = [de || ate, ate || de].minmax
      (inicio..fim).first(MAX_DIAS_VARREDURA)
    else
      entregas.filter_map { |e| e.planned_date&.to_date }.uniq.sort
    end
  end

  def data_param(chave)
    valor = params[chave].to_s.strip
    return nil if valor.blank?

    Date.parse(valor)
  rescue Date::Error
    nil
  end

  # Sem datas não há o que consultar — e o motivo muda a saída para o ADM.
  def sem_datas_msg(nf, entregas)
    if entregas.empty?
      "NF #{nf} não encontrada na base de rastreio. Informe um período para procurar direto no SimpliRoute."
    else
      "NF #{nf} está na base de rastreio mas sem data planejada. Informe um período para procurar na API."
    end
  end

  # Todos os lançamentos da NF, cruzando as DUAS fontes:
  #   • espelho de rastreio (Entrega) — traz motorista/veículo;
  #   • API do SimpliRoute, nas datas varridas — pega a visita duplicada que a
  #     sincronização ainda não trouxe para o espelho.
  def ocorrencias_da_nf(nf, entregas, datas, falhas)
    trackings = entregas.map { |e| e.tracking_id.to_s }
    do_dia    = visitas_dos_dias(datas, nf, falhas)

    visitas = do_dia.select { |v| v['reference'].to_s == nf || trackings.include?(v['tracking_id'].to_s) }
                    .uniq { |v| v['id'] }
    visitas = com_tracking_id(visitas)

    achadas = visitas.map { |v| ocorrencia_da_api(v, entrega_de(entregas, v['tracking_id'])) }

    # Lançamento que existe no espelho mas não apareceu na API (data sem plano,
    # visita removida): entra na lista como não editável, para o ADM ver que
    # ele existe em vez de sumir silenciosamente — que é o bug que estamos
    # corrigindo.
    vistos  = visitas.map { |v| v['tracking_id'].to_s }
    orfas   = entregas.reject { |e| vistos.include?(e.tracking_id.to_s) }
                      .map { |e| ocorrencia_do_espelho(e, datas.include?(e.planned_date&.to_date)) }

    # Mais recente primeiro (a data vem como texto ISO da API e como timestamp
    # do espelho — os 10 primeiros caracteres normalizam as duas).
    (achadas + orfas).sort_by { |o| [o[:data].to_s[0, 10], o[:id].to_i] }.reverse
  end

  # Dias consultados de uma vez. Cada chamada abre a própria conexão HTTP (ver
  # SimpliRoute::Client#requisicao) e não toca o banco, então dá para
  # paralelizar em lotes; o lote pequeno evita martelar a API do SimpliRoute.
  MAX_PARALELO_VARREDURA = 6

  # Passa a NF como `search` — sem isso cada dia baixaria ~3,8 MB (o dia inteiro)
  # e um mês seria inviável. Ver a nota em SimpliRoute::Client#visitas_da_data.
  def visitas_dos_dias(datas, nf, falhas)
    mutex = Mutex.new

    datas.each_slice(MAX_PARALELO_VARREDURA).flat_map do |lote|
      lote.map { |data| Thread.new { visitas_do_dia(data, nf, falhas, mutex) } }.flat_map(&:value)
    end
  end

  # Um dia que a API não responde NÃO pode derrubar a busca inteira: registra a
  # falha e segue com os outros dias. (Foi assim que uma NF com visitas em duas
  # datas passou a não devolver nada em vez de devolver o que deu certo.)
  def visitas_do_dia(data, nf, falhas, mutex)
    client.visitas_da_data(data, busca: nf)
  rescue SimpliRoute::Error => e
    mutex.synchronize { falhas << { data: data.strftime('%d/%m/%Y'), erro: e.message } }
    []
  end

  # Abrir o card do lançamento único é conveniência: se falhar, a lista ainda
  # tem que aparecer.
  def payload_seguro(ocorrencia, entregas, falhas)
    payload_visita(ocorrencia[:id], entrega_de(entregas, ocorrencia[:tracking_id]))
  rescue SimpliRoute::Error => e
    falhas << { data: ocorrencia[:data].to_s[0, 10], erro: "não foi possível abrir o lançamento: #{e.message}" }
    nil
  end

  def entrega_de(entregas, tracking_id)
    return nil if tracking_id.blank?

    entregas.find { |e| e.tracking_id.to_s == tracking_id.to_s }
  end

  # Quantas visitas da lista vale a pena detalhar (1 GET cada). Uma NF com mais
  # que isso é dado estranho, não plano duplicado.
  MAX_DETALHES_LISTA = 5

  # A lista-por-data nem sempre traz o `tracking_id`, e é ele que liga a visita
  # ao espelho — de onde vêm motorista e veículo. Sem esse casamento a NF
  # duplicada aparece com as duas linhas sem motorista, que é exatamente o dado
  # que distingue uma da outra. Quando faltar, busca a visita completa.
  def com_tracking_id(visitas)
    visitas.each_with_index.map do |visita, i|
      next visita if visita['tracking_id'].present? || i >= MAX_DETALHES_LISTA

      detalhar(visita)
    end
  end

  # Best-effort: se o detalhe falhar, segue com o que a lista deu.
  def detalhar(visita)
    client.visita(visita['id']).presence || visita
  rescue SimpliRoute::Error
    visita
  end

  # Resumo de um lançamento para a lista de escolha (não carrega fotos/detalhe —
  # isso só acontece quando o ADM abre um).
  def ocorrencia_da_api(visita, entrega)
    {
      id:            visita['id'],
      tracking_id:   visita['tracking_id'],
      titulo:        visita['title'],
      endereco:      visita['address'],
      status:        visita['status'],
      data:          visita['planned_date'],
      checkout_time: visita['checkout_time'],
      motorista:     entrega&.driver,
      veiculo:       entrega&.vehicle,
      editavel:      true,
      # Sem linha no espelho não há como saber motorista/veículo: a API não
      # devolve esses campos na visita. O ADM diferencia pelo status/checkout.
      no_painel:     entrega.present?,
      aviso:         entrega ? nil : 'Ainda não sincronizado no painel — motorista e veículo indisponíveis'
    }
  end

  def ocorrencia_do_espelho(entrega, data_varrida)
    {
      id:            nil,
      tracking_id:   entrega.tracking_id,
      titulo:        entrega.local,
      endereco:      entrega.address,
      status:        entrega.status,
      data:          entrega.planned_date,
      checkout_time: entrega.checkout,
      motorista:     entrega.driver,
      veiculo:       entrega.vehicle,
      editavel:      false,
      no_painel:     true,
      aviso:         (if data_varrida
                        'Só no painel — não está entre as visitas desta data no SimpliRoute, então não dá para editar'
                      else
                        'Fora do período consultado — inclua a data deste lançamento no período para poder editá-lo'
                      end)
    }
  end

  # Estado completo de UMA visita para o card (inclui a galeria de fotos, que
  # custa uma chamada extra ao POD). `entrega` pode ser nil quando a visita
  # ainda não sincronizou no espelho.
  def payload_visita(id, entrega)
    visita  = client.visita(id)
    # Segunda fonte de imagens (POD). Não é obrigatória: se falhar, vem {}.
    detalhe = client.detalhe_visita(id)

    {
      id:                   id,
      tracking_id:          visita['tracking_id'],
      nf:                   visita['reference'],
      titulo:               visita['title'],
      endereco:             visita['address'],
      status:               visita['status'],
      checkout_observation: visita['checkout_observation'],
      checkout_comment:     visita['checkout_comment'],
      notes:                visita['notes'],
      checkout_time:        visita['checkout_time'],
      checkout_latitude:    visita['checkout_latitude'],
      checkout_longitude:   visita['checkout_longitude'],
      planned_date:         visita['planned_date'],
      contato:              visita['contact_name'],
      telefone:             visita['contact_phone'],
      # Galeria unificada — TODAS as fotos do lançamento, de todas as fontes,
      # cada uma etiquetada para o ADM saber o que está conferindo.
      fotos:                fotos_do_lancamento(entrega, visita, detalhe),
      motorista:            entrega&.driver,
      veiculo:              entrega&.vehicle
    }
  end

  # Monta o hash de PATCH só com os campos que vieram no form E que realmente
  # mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
  def mudancas(anterior)
    CAMPOS_EDITAVEIS.each_with_object({}) do |campo, memo|
      next unless params.key?(campo)

      novo = normalizar(campo, params[campo])
      next if equivalente?(campo, novo, anterior[campo])

      memo[campo] = novo
    end
  end

  # Evita PATCH falso: nil/"" são equivalentes; datas comparadas por instante;
  # lat/long por valor numérico (o reparse difere do texto original da API).
  def equivalente?(campo, novo, atual)
    return true if novo.to_s == atual.to_s

    case campo
    when 'checkout_time'
      a = (Time.zone.parse(novo.to_s) rescue nil)
      b = (Time.zone.parse(atual.to_s) rescue nil)
      a.present? && b.present? && a.to_i == b.to_i
    when 'checkout_latitude', 'checkout_longitude'
      novo.is_a?(Float) && atual.present? && (novo - atual.to_f).abs < 1e-9
    else
      false
    end
  end

  # Normaliza valores por campo. Campos em branco viram nil (permite LIMPAR o
  # motivo/comentário). checkout_time aceita datetime-local e vira ISO8601.
  def normalizar(campo, valor)
    valor = valor.to_s.strip
    return nil if valor.blank?

    case campo
    when 'checkout_latitude', 'checkout_longitude'
      Float(valor) rescue valor
    when 'checkout_time'
      (Time.zone.parse(valor)&.iso8601 rescue nil) || valor
    else
      valor
    end
  end

  # Lista de motivos; nunca quebra a tela se a API estiver fora.
  def motivos_seguros
    client.observations
  rescue SimpliRoute::Error
    []
  end

  def render_erro(msg, status: :unprocessable_entity)
    render json: { ok: false, erro: msg }, status: status
  end

  # Erro do atualizar nos dois formatos (JSON p/ inline edit, HTML p/ fallback).
  def responder_erro(msg, status: :unprocessable_entity)
    respond_to do |format|
      format.json { render json: { ok: false, erro: msg }, status: status }
      format.html { redirect_to admin_edicao_lancamento_path, alert: msg }
    end
  end

  # Só aceita URL http(s) — evita injetar lixo no <img src> do card (mesmo
  # filtro do foto_url do OperacaoMetricas).
  def url_imagem(valor)
    url = valor.to_s.strip
    url.match?(%r{\Ahttps?://}i) ? url : nil
  end

  # Campos de foto customizados do SimpliRoute (extra_field_values). É AQUI que
  # ficam as fotos reais tiradas pelo motorista — o array `pictures` só traz uma
  # imagem genérica. Mapa: campo da API => [rótulo p/ o ADM, categoria de cor].
  # Campos fora deste mapa ainda aparecem (rótulo derivado do nome do campo).
  FOTOS_EXTRA = {
    'foto_prova_visita' => ['Prova da visita', 'prova'],
    'foto_nf'           => ['Nota Fiscal',     'nf'],
    'foto_termo'        => ['Termo',           'termo'],
    'foto_relatorio2'   => ['Relatório',       'relatorio']
  }.freeze

  # Reúne TODAS as imagens do lançamento numa lista única e etiquetada:
  #   1. espelho de rastreio  -> foto da fachada (mesma do mapa de operações)
  #   2. extra_field_values   -> fotos reais do motorista (foto_nf, prova, etc.)
  #   3. pictures[]/signature -> imagens genéricas + assinatura
  #
  # A mesma foto pode vir de mais de uma fonte, então deduplica por URL mantendo
  # a primeira etiqueta (a ordem abaixo é a mais informativa).
  def fotos_do_lancamento(entrega, visita, detalhe)
    brutas = []
    brutas << [entrega.try(:foto_da_fachada), 'Fachada', 'fachada', 'Rastreio']

    # Fotos customizadas — o /detail/ é a fonte mais completa; cai p/ a visita.
    extras = detalhe['extra_field_values'].presence || visita['extra_field_values'] || {}
    extras.each do |campo, valor|
      rotulo, cat = FOTOS_EXTRA[campo] || [rotulo_do_campo(campo), 'extra']
      brutas << [valor, rotulo, cat, 'SimpliRoute']
    end

    [[visita, 'Visita'], [detalhe, 'Comprovante']].each do |fonte, origem|
      Array(fonte['pictures']).each { |u| brutas << [u, 'Comprovante', 'entrega', origem] }
      brutas << [fonte['signature'], 'Assinatura', 'assinatura', origem]
    end

    vistas = Set.new
    brutas.filter_map do |valor, rotulo, cat, origem|
      url = url_imagem(valor)
      next if url.nil? || !vistas.add?(url)

      { url: url, rotulo: rotulo, cat: cat, origem: origem }
    end
  end

  # "foto_relatorio2" -> "Relatorio2". Fallback p/ campos de foto não mapeados.
  def rotulo_do_campo(campo)
    campo.to_s.sub(/\Afoto_?/, '').tr('_', ' ').strip.capitalize.presence || campo.to_s
  end

  # Sem token não há o que fazer — avisa e volta.
  def garantir_configurado
    return if SimpliRoute.configurado?

    msg = 'Integração com o SimpliRoute não configurada (defina SIMPLIROUTE_TOKEN no servidor).'
    respond_to do |format|
      format.html { redirect_to dashboard_path, alert: msg }
      format.json { render json: { ok: false, erro: msg }, status: :service_unavailable }
    end
  end
end
