# app/services/analytics/operacao_metricas.rb
#
# Núcleo de dados do "Dashboard de Operações".
#
# Espelha a query de gestão do cliente: casa db_reem_simplerout_2026 com a(s)
# tabela(s) de operação (gade_entregas_*) por reference_id::text = nota_fiscal e
# agrega em Ruby para alimentar os painéis (KPIs, insucessos %, índices de falha,
# status, motoristas, STS, por dia e o mapa de calor).
#
# DUAS CAMADAS, de propósito:
#   #visitas — uma linha por ida do motorista ao local. É o que o dashboard
#              financeiro conta (Entrega.contar_atendidas) e o que se paga ao
#              motorista.
#   #linhas  — uma linha por NOTA FISCAL (a última visita de cada uma). É o que
#              o cliente paga, o que confere com os documentos físicos e com a
#              aba ENTREGAS da planilha. TODOS os KPIs desta tela saem daqui.
# Os dois números só coincidem quando nenhuma NF precisou de segunda ida; a
# diferença aparece na tela como "retentativas" em vez de ficar escondida.
#
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA.
module Analytics
  class OperacaoMetricas
    # Centro padrão do mapa quando não há coordenadas (Grande São Paulo).
    SP_CENTRO = [-23.55, -46.63].freeze

    # inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a
    # própria operação inteira (visão de operação única). O filtro de período só é
    # usado na visão Global.
    #
    # filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
    # numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
    # dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
    def initialize(tabelas:, inicio: nil, fim: nil, filtros: {}, data: nil)
      @tabelas = Operacao.sanitizar(Array(tabelas))
      @inicio  = inicio&.to_date
      @fim     = fim&.to_date
      @filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
      @data    = data&.to_date   # filtro por DIA (clique no gráfico "Entregas por Dia")
    end

    def operacoes_label
      @tabelas.map { |t| Operacao.label(t) }.join(', ')
    end

    # VISITAS após o cross-filter: uma linha por passagem do motorista. Uma mesma
    # NF pode ter várias (falhou dia 10, entregou dia 12). É a base da análise
    # operacional — quantas idas ao local foram necessárias.
    def visitas
      @visitas ||= begin
        base = @filtros.empty? ? registros
                               : registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
        @data ? base.select { |r| data_de(r['checkout']) == @data } : base
      end
    end

    # NOTAS FISCAIS: uma linha por NF, com a ÚLTIMA visita dela. É a base de
    # TODOS os KPIs desta tela, porque é o que confere com os documentos físicos
    # e com a aba ENTREGAS da planilha entregue ao cliente (Analytics::
    # PlanilhaEntregas também usa "último status por reference_id").
    #
    # ⚠️ O dedup precisa acontecer AQUI — depois do período e do cross-filter — e
    # não no SQL. Enquanto ele rodava como ROW_NUMBER + `rn = 1` sobre a tabela
    # INTEIRA, uma NF reentregue DEPOIS do fim do período perdia a visita que
    # estava DENTRO dele e sumia da contagem do mês.
    def linhas
      @linhas ||= por_nota.values.map { |vs| vs.max_by { |r| ordem_visita(r) } }
    end

    # Campos pesquisáveis da tabela espelho (planilha da operação).
    BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
                      address nome_completo endereco_completo status_gade operacao].freeze

    # Busca textual da tabela espelho: filtra as linhas (já pós cross-filter)
    # por qualquer campo exibido na planilha.
    def buscar(texto)
      termo = texto.to_s.strip.downcase
      return linhas if termo.empty?
      linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } }
    end

    # Taxas (úteis no comparativo).
    def taxa_sucesso
      pct(sucesso, total)
    end

    def taxa_insucesso
      pct(recusas, total)
    end

    # Faixa real das entregas carregadas (pela data de checkout) — para exibir o
    # período natural da operação no cabeçalho/painel.
    def data_inicio
      datas_checkout.min
    end

    def data_fim
      datas_checkout.max
    end

    # Linhas cruas (Array de Hash com chave string) — base de todas as agregações.
    def registros
      @registros ||= carregar
    end

    def vazio?
      registros.empty?
    end

    # ── KPIs ─────────────────────────────────────────────────────
    def total
      linhas.size
    end

    def sucesso
      linhas.count { |r| r['status'] == 'completed' }
    end

    def recusas
      linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
    end

    # Em aberto: nem concluídas nem falhadas.
    def pendentes
      total - sucesso - recusas
    end

    # NFs da operação cujo resultado caiu dentro de uma faixa de datas —
    # REFERÊNCIA para o cabeçalho da tela de operação única, onde o total
    # continua sendo a operação inteira (é o que confere com a planilha do
    # cliente). Mesma regra do filtro SQL: concluídas/falhas pela data real
    # (checkout), pendentes pela data planejada.
    def total_no_periodo(inicio, fim)
      return total unless inicio && fim

      ini = inicio.to_date
      fim = fim.to_date
      linhas.count do |r|
        d = data_de(r['checkout']) || data_de(r['planned_date'])
        d && d >= ini && d <= fim
      end
    end

    # ── Camada operacional (por VISITA, não por NF) ───────────────
    # Os KPIs acima contam NOTAS porque é o que o cliente paga e confere. Estes
    # contam IDAS AO LOCAL — é o que o motorista recebe e o que o dashboard
    # financeiro usa (Entrega.contar_atendidas conta linhas). Sem eles, uma NF
    # que falhou duas vezes antes de ser entregue aparecia como 100% de sucesso
    # e o insucesso sumia da tela.
    def total_visitas
      visitas.size
    end

    # Idas ao local além da primeira de cada NF.
    def retentativas
      total_visitas - total
    end

    def visitas_insucesso
      visitas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
    end

    # NFs que hoje constam como ENTREGUES mas custaram mais de uma ida — a
    # informação que o dedup escondia por completo.
    def notas_reentregues
      @notas_reentregues ||= por_nota.count do |_nf, vs|
        vs.any? { |r| Entrega::STATUS_FALHA.include?(r['status']) } &&
          vs.max_by { |r| ordem_visita(r) }['status'] == 'completed'
      end
    end

    # Donut "Insucessos %": completas vs falhas (sobre o total).
    def insucessos_pct
      {
        completed:     sucesso,
        failed:        recusas,
        pct_completed: pct(sucesso, total),
        pct_failed:    pct(recusas, total)
      }
    end

    # Falhas agrupadas por observação (motivo), % sobre o total de falhas.
    def indices_falha
      falhas = linhas.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
      total_falhas = falhas.size
      falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
            .map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
            .sort_by { |h| -h[:total] }
    end

    # Contagem por status da tabela gade (RECORRENTE/NOVO/...).
    def por_status_gade
      linhas.group_by { |r| r['status_gade'].presence || '—' }
            .map { |st, rows| { status: st, total: rows.size } }
            .sort_by { |h| -h[:total] }
    end

    # NFs por motorista (todas as entregas), desc.
    def por_motorista
      contagem(linhas, 'driver')
    end

    # Entregas CONCLUÍDAS por unidade (contact_name), desc.
    def por_sts
      contagem(linhas.select { |r| r['status'] == 'completed' }, 'contact_name')
    end

    # Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
    def por_dia
      por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
      linhas.each do |r|
        d = data_de(r['checkout'])
        next unless d
        if r['status'] == 'completed'
          por_data[d][:completed] += 1
        elsif Entrega::STATUS_FALHA.include?(r['status'])
          por_data[d][:failed] += 1
        end
      end
      datas = por_data.keys.sort
      {
        labels:    datas.map { |d| d.strftime('%d/%m/%Y') },
        completed: datas.map { |d| por_data[d][:completed] },
        failed:    datas.map { |d| por_data[d][:failed] }
      }
    end

    # Pontos [lat, lng] das entregas ATENDIDAS (sucesso + falhas) para o heatmap.
    # Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
    def pontos_mapa
      @pontos_mapa ||= linhas.filter_map do |r|
        next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
        lat = num(r['checkout_latitude'])  || num(r['latitude'])
        lng = num(r['checkout_longitude']) || num(r['longitude'])
        next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
        [lat, lng]
      end
    end

    def centro_mapa
      pontos_mapa.first || SP_CENTRO
    end

    # Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares).
    MAX_PONTOS = 2000

    # Pontos detalhados (modo "Pontos" do mapa) — um por entrega ATENDIDA
    # (sucesso OU insucesso: óbito e demais pilares de falha), com coordenada de
    # CHECKOUT (ou planejada como fallback). Inclui dados para o popup (NF,
    # destinatário, endereço, unidade, motorista, veículo, data, status, motivo).
    def pontos_detalhados
      @pontos_detalhados ||= linhas.filter_map do |r|
        next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
        lat = num(r['checkout_latitude'])  || num(r['latitude'])
        lng = num(r['checkout_longitude']) || num(r['longitude'])
        next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
        {
          lat: lat, lng: lng,
          nf:        r['reference_id'],
          nome:      r['nome_completo'].presence || r['contact_name'].presence || '—',
          endereco:  r['endereco_completo'].presence || r['address'].presence,
          unidade:   r['contact_name'],
          motorista: r['driver'],
          veiculo:   r['vehicle'],
          checkout:  data_de(r['checkout'])&.strftime('%d/%m/%Y'),
          sucesso:   r['status'] == 'completed',
          motivo:    r['observation'].presence,
          foto:      foto_url(r['foto_da_fachada'])
        }
      end.first(MAX_PONTOS)
    end

    private

    # NF => visitas dela (já filtradas). Chave string: reference_id é numérico no
    # espelho e texto na tabela da operação.
    def por_nota
      @por_nota ||= visitas.group_by { |r| r['reference_id'].to_s }
    end

    # Ordem de "última visita": quem tem checkout ganha de quem não tem e, entre
    # as com checkout, vence a mais recente. Mesma regra do ROW_NUMBER que a
    # planilha do cliente usa (checkout DESC NULLS LAST).
    def ordem_visita(registro)
      t = tempo_de(registro['checkout'])
      [t ? 1 : 0, t || Time.at(0)]
    end

    def tempo_de(valor)
      return nil if valor.nil? || valor.to_s.strip.empty?
      valor.to_time
    rescue ArgumentError, TypeError, NoMethodError
      nil
    end

    def datas_checkout
      @datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
    end

    def contagem(rows, coluna)
      rows.group_by { |r| r[coluna].presence || '—' }
          .map { |nome, grupo| { nome: nome, total: grupo.size } }
          .sort_by { |h| -h[:total] }
    end

    def pct(parte, total)
      return 0.0 if total.to_i.zero?
      (parte.to_f / total * 100).round(2)
    end

    def num(valor)
      return nil if valor.nil? || valor.to_s.strip.empty?
      Float(valor)
    rescue ArgumentError, TypeError
      nil
    end

    def data_de(valor)
      return nil if valor.nil?
      return valor.to_date if valor.respond_to?(:to_date)
      Date.parse(valor.to_s)
    rescue ArgumentError, TypeError
      nil
    end

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

    def carregar
      return [] if @tabelas.empty?

      conn = ActiveRecord::Base.connection

      rastreio = conn.quote_table_name(Entrega.table_name)
      conta    = Entrega.condicao_conta_sql('r')

      unions = @tabelas.map do |tabela|
        gade  = conn.quote_table_name(tabela)
        label = conn.quote(Operacao.label(tabela))
        <<~SQL.strip
          SELECT #{label} AS operacao,
                 r.tracking_id,
                 r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
                 r.checkout, r.planned_date, r.foto_da_fachada,
                 r.latitude, r.longitude,
                 r.checkout_latitude, r.checkout_longitude,
                 #{selects_gade(conn, tabela)}
          FROM #{rastreio} r
          INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
          WHERE r.reference_id IS NOT NULL#{conta ? " AND #{conta}" : ''}#{filtro_periodo(conn)}
        SQL
      end

      # Traz TODAS as visitas; a redução para uma linha por NF é feita em #linhas,
      # já depois do período e do cross-filter (ver o comentário lá).
      #
      # `uniq` por tracking_id: se a mesma nota_fiscal estiver repetida dentro de
      # uma tabela de operação (ou em duas tabelas do UNION), o INNER JOIN
      # devolveria a MESMA visita mais de uma vez e inflaria a contagem.
      conn.select_all(unions.join("\nUNION ALL\n")).to_a.uniq { |r| r['tracking_id'] }
    end

    # Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
    # ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
    # o UNION entre operações continua consistente. (A única coluna garantida em
    # toda tabela gade é nota_fiscal.) As chaves são uma whitelist fixa (não entram
    # dados do usuário no SQL).
    GADE_OPCIONAIS = { 'status' => 'status_gade', 'nome_completo' => 'nome_completo',
                       'endereco_completo' => 'endereco_completo' }.freeze

    def selects_gade(conn, tabela)
      colunas = conn.columns(tabela).map(&:name)
      GADE_OPCIONAIS.map do |origem, apelido|
        colunas.include?(origem) ? "g.#{origem} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
      end.join(", ")
    end

    # Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
    # Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
    # data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
    def filtro_periodo(conn)
      return '' unless @inicio && @fim
      ini      = conn.quote(@inicio)
      fim_excl = conn.quote(@fim + 1)
      fim_dia  = conn.quote(@fim.end_of_day)
      " AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
        " OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
    end
  end
end
