defmodule Towerops.Workers.DevicePollerWorker do
  @moduledoc """
  Oban worker that performs SNMP polling for a device.

  This worker continuously collects:
  - Sensor readings (temperature, voltage, CPU, memory, etc.)
  - Interface statistics (bandwidth, errors, discards, etc.)
  - Neighbor discovery (LLDP/CDP topology information)
  - ARP and MAC tables
  - Processor and storage statistics

  Uses Oban's unique job feature to ensure only one poller per device
  runs cluster-wide. Poll interval is configurable per device (default: 60 seconds).
  """
  use Oban.Worker,
    queue: :pollers,
    unique: [
      period: 60,
      keys: [:device_id],
      states: [:available, :scheduled, :executing, :retryable]
    ]

  alias Towerops.Agents
  alias Towerops.Devices
  alias Towerops.JobMonitoring.Events
  alias Towerops.Snmp
  alias Towerops.Snmp.ArpDiscovery
  alias Towerops.Snmp.Client
  alias Towerops.Snmp.MacDiscovery
  alias Towerops.Snmp.NeighborDiscovery
  alias Towerops.Snmp.SensorChangeDetector
  alias Towerops.Workers.PollingOffset

  require Logger

  @default_poll_interval 60

  @impl Oban.Worker
  @spec perform(Oban.Job.t()) :: :ok
  def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
    Events.broadcast_job_event(job, :started)
    start_time = System.monotonic_time(:second)

    result =
      case Devices.get_device(device_id) do
        nil ->
          Logger.debug("Device #{device_id} no longer exists, skipping poll and not rescheduling")
          :ok

        device ->
          # Only reschedule if device still has polling enabled
          if should_continue_polling?(device) do
            maybe_poll_device(device)
            schedule_next_poll_with_error_handling(device_id, device)
          else
            Logger.debug("Device #{device_id} polling disabled, not rescheduling",
              device_id: device_id,
              snmp_enabled: device.snmp_enabled
            )
          end

          :ok
      end

    duration = System.monotonic_time(:second) - start_time

    Events.broadcast_job_event(job, :completed, %{duration: duration})

    result
  end

  # Check if device should continue being polled
  # Returns false if Phoenix SNMP is disabled, SNMP is disabled on device, or device is assigned to an agent
  defp should_continue_polling?(device) do
    !Client.phoenix_snmp_disabled() && device.snmp_enabled &&
      Agents.should_phoenix_poll_device?(device)
  end

  defp maybe_poll_device(device) do
    cond do
      Client.phoenix_snmp_disabled() ->
        :ok

      not device.snmp_enabled ->
        :ok

      not Agents.should_phoenix_poll_device?(device) ->
        Logger.debug("Skipping Phoenix poll for device #{device.name} - assigned to non-cloud-poller agent")
        :ok

      true ->
        poll_device(device)
    end
  end

  defp schedule_next_poll_with_error_handling(device_id, _stale_device) do
    # Re-fetch device to get latest check_interval_seconds
    # Configuration could have changed during the polling execution
    case Devices.get_device(device_id) do
      nil ->
        Logger.debug("Device deleted, not rescheduling", device_id: device_id)
        :ok

      fresh_device ->
        poll_interval = get_poll_interval(fresh_device)

        case schedule_next_poll(device_id, poll_interval) do
          {:ok, _job} ->
            :ok

          {:error, changeset} ->
            Logger.error("Failed to schedule next poll for device #{device_id}: #{inspect(changeset.errors)}")
            :ok
        end
    end
  end

  @doc """
  Starts polling for a device.
  """
  def start_polling(device_id) do
    device = Devices.get_device!(device_id)
    interval = get_poll_interval(device)
    offset = PollingOffset.calculate_offset(device_id, interval)

    %{device_id: device_id}
    |> new(schedule_in: offset)
    |> Oban.insert()
  end

  @doc """
  Stops polling for a device by cancelling its jobs.
  """
  def stop_polling(device_id) do
    import Ecto.Query

    Oban.cancel_all_jobs(
      from(j in Oban.Job,
        where: j.worker == "Towerops.Workers.DevicePollerWorker",
        where: fragment("args->>'device_id' = ?", ^device_id),
        where: j.state in ["available", "scheduled", "executing", "retryable"]
      )
    )
  end

  @doc """
  Triggers an immediate poll for a device.
  """
  def trigger_poll(device_id) do
    # Insert a new job to run immediately
    %{device_id: device_id}
    |> new()
    |> Oban.insert()
  end

  # Private Functions

  defp poll_device(device) do
    snmp_device = Snmp.get_device_with_associations(device.id)

    if snmp_device && (snmp_device.sensors != [] || snmp_device.interfaces != []) do
      poll_interval = get_poll_interval(device)

      if should_skip_poll?(device, poll_interval, 5) do
        Logger.debug("Skipping poll for #{device.name} - recently polled by another process")
      else
        perform_device_data_collection(device, snmp_device)
      end
    end
  end

  defp perform_device_data_collection(device, snmp_device) do
    Devices.update_snmp_poll_time(device)

    client_opts = build_client_opts(device)
    now = DateTime.truncate(DateTime.utc_now(), :second)

    # Run all polling operations in parallel using async tasks
    task_names = [
      "sensors",
      "state_sensors",
      "interfaces",
      "interface_changes",
      "neighbors",
      "arp",
      "mac",
      "processors",
      "storage"
    ]

    tasks = [
      Task.async(fn -> poll_device_sensors(device, snmp_device, client_opts, now) end),
      Task.async(fn -> poll_device_state_sensors(device, snmp_device, client_opts, now) end),
      Task.async(fn -> poll_device_interfaces(device, snmp_device, client_opts, now) end),
      Task.async(fn -> check_device_interface_changes(device, snmp_device, client_opts, now) end),
      Task.async(fn -> poll_device_neighbors(device, snmp_device, client_opts) end),
      Task.async(fn -> poll_device_arp(device, snmp_device, client_opts) end),
      Task.async(fn -> poll_device_mac(device, snmp_device, client_opts) end),
      Task.async(fn -> poll_device_processors(device, snmp_device, client_opts, now) end),
      Task.async(fn -> poll_device_storage(device, snmp_device, client_opts, now) end)
    ]

    # Wait for all tasks with timeout, log failures
    tasks
    |> Task.yield_many(30_000)
    |> Enum.zip(task_names)
    |> Enum.each(fn {{task, result}, name} ->
      case result do
        {:ok, _value} ->
          :ok

        {:exit, reason} ->
          Logger.warning("Polling task '#{name}' crashed for #{device.name}: #{inspect(reason)}",
            device_id: device.id,
            task: name
          )

        nil ->
          Task.shutdown(task, :brutal_kill)

          Logger.warning("Polling task '#{name}' timed out for #{device.name}",
            device_id: device.id,
            task: name
          )
      end
    end)

    # Re-check assignment before processing results
    # Device could have been reassigned to an agent during the 30-second polling window
    case verify_polling_assignment_unchanged(device.id) do
      :ok ->
        # Assignment unchanged - process results normally
        :ok

      :reassigned ->
        Logger.info("Device reassigned during polling, discarding results",
          device_id: device.id,
          device_name: device.name
        )

        :ok
    end
  end

  # Verify device assignment hasn't changed since polling started
  defp verify_polling_assignment_unchanged(device_id) do
    case Devices.get_device(device_id) do
      nil ->
        # Device deleted during polling
        :reassigned

      device ->
        if Agents.should_phoenix_poll_device?(device) do
          :ok
        else
          :reassigned
        end
    end
  end

  defp poll_device_sensors(device, snmp_device, client_opts, now) do
    poll_sensors(snmp_device.sensors, client_opts, now)
    Logger.debug("Polled #{length(snmp_device.sensors)} sensors for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:sensors_updated, device.id}
      )
  rescue
    error ->
      Logger.error("Error polling sensors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_state_sensors(device, snmp_device, client_opts, now) do
    poll_state_sensors(snmp_device.state_sensors, client_opts, now, device.id)
    Logger.debug("Polled #{length(snmp_device.state_sensors)} state sensors for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:state_sensors_updated, device.id}
      )
  rescue
    error ->
      Logger.error("Error polling state sensors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_interfaces(device, snmp_device, client_opts, now) do
    poll_interfaces(snmp_device.interfaces, client_opts, now)
    Logger.debug("Polled #{length(snmp_device.interfaces)} interfaces for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:interfaces_updated, device.id}
      )
  rescue
    error ->
      Logger.error("Error polling interfaces for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp check_device_interface_changes(device, snmp_device, client_opts, now) do
    check_interface_changes(snmp_device.interfaces, device, client_opts, now)
  rescue
    error ->
      Logger.error(
        "Error checking interface changes for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
      )
  end

  defp poll_device_neighbors(device, snmp_device, client_opts) do
    interfaces_with_device = Enum.map(snmp_device.interfaces, &Map.put(&1, :device_id, device.id))

    {:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces_with_device)

    cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
    Snmp.delete_stale_and_upsert_neighbors(device.id, neighbors, cutoff)

    Logger.debug("Polled and saved #{length(neighbors)} neighbors for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:neighbors_updated, device.id}
      )

    # Run topology inference after neighbor data is saved
    case Towerops.Topology.process_device(device, device.organization_id) do
      {:ok, :changed} ->
        Logger.debug("Topology updated for #{device.name}")

      {:ok, :unchanged} ->
        :ok
    end
  rescue
    error ->
      Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_arp(device, snmp_device, client_opts) do
    {:ok, arp_entries} = ArpDiscovery.discover_arp_table(client_opts)

    cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
    Snmp.delete_stale_and_upsert_arp_entries(device.id, arp_entries, snmp_device.interfaces, cutoff)

    Logger.debug("Polled and saved #{length(arp_entries)} ARP entries for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:arp_updated, device.id}
      )
  rescue
    error ->
      Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_mac(device, snmp_device, client_opts) do
    {:ok, mac_entries} = MacDiscovery.discover_mac_table(client_opts)

    cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
    Snmp.delete_stale_and_upsert_mac_addresses(device.id, mac_entries, snmp_device.interfaces, cutoff)

    Logger.debug("Polled and saved #{length(mac_entries)} MAC FDB entries for #{device.name}")

    _ =
      Phoenix.PubSub.broadcast(
        Towerops.PubSub,
        "device:#{device.id}",
        {:mac_updated, device.id}
      )
  rescue
    error ->
      Logger.error("Error polling MAC FDB table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_processors(device, snmp_device, client_opts, now) do
    processors = snmp_device.processors || []

    if processors != [] do
      poll_processors(processors, client_opts, now)
      Logger.debug("Polled processors for #{device.name}")

      _ =
        Phoenix.PubSub.broadcast(
          Towerops.PubSub,
          "device:#{device.id}",
          {:processors_updated, device.id}
        )
    end
  rescue
    error ->
      Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  defp poll_device_storage(device, snmp_device, client_opts, now) do
    storage_entries = snmp_device.storage || []

    if storage_entries != [] do
      poll_storage(storage_entries, client_opts, now)
      Logger.debug("Polled #{length(storage_entries)} storage entries for #{device.name}")

      _ =
        Phoenix.PubSub.broadcast(
          Towerops.PubSub,
          "device:#{device.id}",
          {:storage_updated, device.id}
        )
    end
  rescue
    error ->
      Logger.error("Error polling storage for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
  end

  # All the helper functions from the original PollerWorker GenServer
  # (These remain unchanged - I'll include them all below)

  defp poll_storage(storage_entries, client_opts, timestamp) do
    poll_results =
      storage_entries
      |> Task.async_stream(
        fn storage ->
          result = poll_storage_value(storage, client_opts)
          {storage, result}
        end,
        max_concurrency: 2,
        timeout: 40_000,
        on_timeout: :kill_task
      )
      |> Enum.flat_map(fn
        {:ok, pair} -> [pair]
        {:exit, _reason} -> []
      end)

    # Build batch reading entries (only for successful polls)
    reading_entries =
      Enum.flat_map(poll_results, fn
        {storage, {:ok, values}} ->
          [
            %{
              storage_id: storage.id,
              used_bytes: values.used_bytes,
              total_bytes: values.total_bytes,
              usage_percent: values.usage_percent,
              checked_at: timestamp
            }
          ]

        {_storage, {:error, _reason}} ->
          []
      end)

    Snmp.create_storage_readings_batch(reading_entries)

    # Process metadata updates individually
    Enum.each(poll_results, fn
      {storage, {:ok, values}} ->
        Snmp.update_storage(storage, %{
          used_bytes: values.used_bytes,
          total_bytes: values.total_bytes,
          last_checked_at: timestamp
        })

      {storage, {:error, reason}} ->
        Logger.debug("Failed to poll storage #{storage.description}: #{inspect(reason)}")
    end)
  end

  defp poll_storage_value(storage, client_opts) do
    used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{storage.storage_index}"
    size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{storage.storage_index}"
    alloc_oid = "1.3.6.1.2.1.25.2.3.1.4.#{storage.storage_index}"

    with {:ok, used_units} <- Client.get(client_opts, used_oid),
         {:ok, size_units} <- Client.get(client_opts, size_oid),
         {:ok, alloc_units} <- Client.get(client_opts, alloc_oid),
         true <- is_integer(used_units) and is_integer(size_units) and is_integer(alloc_units),
         true <- size_units > 0 do
      used_bytes = used_units * alloc_units
      total_bytes = size_units * alloc_units
      usage_percent = used_bytes / total_bytes * 100

      {:ok, %{used_bytes: used_bytes, total_bytes: total_bytes, usage_percent: usage_percent}}
    else
      {:error, reason} -> {:error, reason}
      false -> {:error, :invalid_values}
    end
  end

  defp poll_processors(processors, client_opts, timestamp) do
    poll_results =
      processors
      |> Task.async_stream(
        fn processor ->
          result = poll_processor_value(processor, client_opts)
          {processor, result}
        end,
        max_concurrency: 2,
        timeout: 40_000,
        on_timeout: :kill_task
      )
      |> Enum.flat_map(fn
        {:ok, pair} -> [pair]
        {:exit, _reason} -> []
      end)

    # Build batch reading entries
    reading_entries =
      Enum.map(poll_results, fn {processor, result} ->
        build_processor_reading_entry(processor, result, timestamp)
      end)

    Snmp.create_processor_readings_batch(reading_entries)

    # Process metadata updates individually
    Enum.each(poll_results, fn {processor, result} ->
      handle_processor_poll_metadata(processor, result, timestamp)
    end)
  end

  defp poll_processor_value(processor, client_opts) do
    # Extract numeric index from processor_index (e.g., "hr_196608" -> "196608")
    numeric_index = processor.processor_index |> String.split("_") |> List.last()

    oid =
      case processor.processor_type do
        "hr_processor" ->
          "1.3.6.1.2.1.25.3.3.1.2.#{numeric_index}"

        "cisco_cpu" ->
          "1.3.6.1.4.1.9.9.109.1.1.1.1.8.#{numeric_index}"

        "ucd_cpu" ->
          poll_ucd_cpu(processor, client_opts)

        _ ->
          {:error, :unknown_processor_type}
      end

    case oid do
      {:ok, value} -> {:ok, value}
      {:error, _} = error -> error
      oid_string when is_binary(oid_string) -> poll_snmp_load(client_opts, oid_string)
    end
  end

  defp poll_snmp_load(client_opts, oid) do
    case Client.get(client_opts, oid) do
      {:ok, value} when is_integer(value) ->
        {:ok, value / 1.0}

      {:ok, _non_integer} ->
        {:error, :non_integer}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp poll_ucd_cpu(_processor, client_opts) do
    user_oid = "1.3.6.1.4.1.2021.11.9.0"
    system_oid = "1.3.6.1.4.1.2021.11.10.0"

    with {:ok, user} <- Client.get(client_opts, user_oid),
         {:ok, system} <- Client.get(client_opts, system_oid),
         true <- is_integer(user) and is_integer(system) do
      {:ok, (user + system) / 1.0}
    else
      _ -> {:error, :failed_to_calculate}
    end
  end

  defp build_processor_reading_entry(processor, {:ok, load_percent}, timestamp) do
    %{
      processor_id: processor.id,
      load_percent: load_percent,
      status: "ok",
      checked_at: timestamp
    }
  end

  defp build_processor_reading_entry(processor, {:error, reason}, timestamp) do
    Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}")

    %{
      processor_id: processor.id,
      load_percent: nil,
      status: "error",
      checked_at: timestamp
    }
  end

  defp handle_processor_poll_metadata(processor, {:ok, load_percent}, timestamp) do
    Snmp.update_processor(processor, %{
      load_percent: load_percent,
      last_checked_at: timestamp
    })
  end

  defp handle_processor_poll_metadata(_processor, {:error, _reason}, _timestamp), do: :ok

  defp poll_sensors(sensors, client_opts, timestamp) do
    # Poll all sensors concurrently and collect results
    poll_results =
      sensors
      |> Task.async_stream(
        fn sensor ->
          result = poll_sensor_value(sensor, client_opts)
          {sensor, result}
        end,
        max_concurrency: 2,
        timeout: 40_000,
        on_timeout: :kill_task
      )
      |> Enum.flat_map(fn
        {:ok, pair} ->
          [pair]

        {:exit, reason} ->
          Logger.warning("Sensor poll task failed: #{inspect(reason)}")
          []
      end)

    # Build batch reading entries
    reading_entries =
      Enum.map(poll_results, fn {sensor, result} ->
        build_sensor_reading_entry(sensor, result, timestamp)
      end)

    # Batch insert all sensor readings
    Snmp.create_sensor_readings_batch(reading_entries)

    # Process metadata updates and change detection individually
    Enum.each(poll_results, fn {sensor, result} ->
      handle_sensor_poll_metadata(sensor, result, timestamp)
    end)
  end

  defp poll_state_sensors(state_sensors, client_opts, timestamp, device_id) do
    state_sensors
    |> Task.async_stream(
      fn state_sensor ->
        result = poll_state_sensor_value(state_sensor, client_opts)
        handle_state_sensor_poll_result(state_sensor, result, timestamp, device_id)
        state_sensor.sensor_descr
      end,
      max_concurrency: 2,
      timeout: 40_000,
      on_timeout: :kill_task
    )
    |> Enum.each(fn
      {:ok, _} -> :ok
      {:exit, reason} -> Logger.warning("State sensor poll task failed: #{inspect(reason)}")
    end)
  end

  defp poll_state_sensor_value(state_sensor, client_opts) do
    case Client.get(client_opts, state_sensor.sensor_oid) do
      {:ok, raw_value} when is_integer(raw_value) ->
        {:ok, raw_value}

      {:ok, _non_integer} ->
        {:error, :non_integer}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp handle_state_sensor_poll_result(state_sensor, {:ok, new_state_value}, timestamp, device_id) do
    old_state_value = state_sensor.state_value
    old_state_descr = state_sensor.state_descr
    old_status = state_sensor.status

    new_status = entity_state_to_status(new_state_value)
    new_descr = entity_state_to_descr(new_state_value)

    Snmp.update_state_sensor(state_sensor, %{
      state_value: new_state_value,
      state_descr: new_descr,
      status: new_status,
      last_checked_at: timestamp
    })

    # Track any state value change, not just status changes
    # This catches changes like "6X (64QAM)" -> "8X (256QAM)" that have the same status
    if old_state_value != nil && old_state_value != new_state_value do
      state_changes = %{
        old_state_value: old_state_value,
        new_state_value: new_state_value,
        old_state_descr: old_state_descr,
        new_state_descr: new_descr,
        old_status: old_status,
        new_status: new_status
      }

      broadcast_state_sensor_change(state_sensor, state_changes, device_id, timestamp)
    end
  end

  defp handle_state_sensor_poll_result(state_sensor, {:error, reason}, timestamp, _device_id) do
    Logger.debug("Failed to poll state sensor #{state_sensor.sensor_descr}: #{inspect(reason)}")

    Snmp.update_state_sensor(state_sensor, %{
      status: "unknown",
      last_checked_at: timestamp
    })
  end

  defp broadcast_state_sensor_change(state_sensor, state_changes, device_id, timestamp) do
    %{
      old_state_value: old_state_value,
      new_state_value: new_state_value,
      old_state_descr: old_state_descr,
      new_state_descr: new_state_descr,
      old_status: old_status,
      new_status: new_status
    } = state_changes

    severity = determine_state_change_severity(old_status, new_status)
    event_type = determine_state_change_event_type(new_status)

    # Format message like: "RX Modulation Rate changed from 6X (64QAM)(6) to 8X (256QAM)(8)"
    message =
      "#{state_sensor.sensor_descr} changed from #{old_state_descr}(#{old_state_value}) to #{new_state_descr}(#{new_state_value})"

    event = %{
      device_id: device_id,
      event_type: event_type,
      severity: severity,
      message: message,
      metadata: %{
        state_sensor_id: state_sensor.id,
        sensor_name: state_sensor.sensor_descr,
        entity_type: state_sensor.entity_type,
        old_state_value: old_state_value,
        new_state_value: new_state_value,
        old_state_descr: old_state_descr,
        new_state_descr: new_state_descr,
        old_status: old_status,
        new_status: new_status
      },
      occurred_at: timestamp
    }

    _ = Phoenix.PubSub.broadcast(Towerops.PubSub, "device:#{device_id}", {:device_event, event})
    _ = Phoenix.PubSub.broadcast(Towerops.PubSub, "device:events", {:device_event, event})

    Logger.info("State sensor change: #{event.message}")
  end

  defp determine_state_change_severity(_old_status, "critical"), do: "critical"
  defp determine_state_change_severity(_old_status, "warning"), do: "warning"
  defp determine_state_change_severity("critical", "ok"), do: "info"
  defp determine_state_change_severity("warning", "ok"), do: "info"
  defp determine_state_change_severity(_old_status, _new_status), do: "info"

  defp determine_state_change_event_type("critical"), do: "state_sensor_critical"
  defp determine_state_change_event_type("warning"), do: "state_sensor_warning"
  defp determine_state_change_event_type("ok"), do: "state_sensor_normal"
  defp determine_state_change_event_type(_), do: "state_sensor_unknown"

  defp entity_state_to_status(1), do: "unknown"
  defp entity_state_to_status(2), do: "ok"
  defp entity_state_to_status(3), do: "warning"
  defp entity_state_to_status(4), do: "unknown"
  defp entity_state_to_status(_), do: "unknown"

  defp entity_state_to_descr(1), do: "unknown"
  defp entity_state_to_descr(2), do: "enabled"
  defp entity_state_to_descr(3), do: "disabled"
  defp entity_state_to_descr(4), do: "testing"
  defp entity_state_to_descr(_), do: "unknown"

  defp poll_sensor_value(sensor, client_opts) do
    if sensor.metadata["calculation"] == "percentage" do
      poll_percentage_sensor(sensor, client_opts)
    else
      poll_simple_sensor(sensor, client_opts)
    end
  end

  # Build a reading entry map for batch insert, or nil if not applicable
  defp build_sensor_reading_entry(sensor, {:ok, value}, timestamp) do
    state_descr = get_state_description(sensor, value)

    %{
      sensor_id: sensor.id,
      value: value,
      status: "ok",
      state_descr: state_descr,
      checked_at: timestamp
    }
  end

  defp build_sensor_reading_entry(sensor, {:error, :non_numeric}, timestamp) do
    Logger.debug("Non-numeric SNMP value for sensor #{sensor.sensor_descr}")

    %{
      sensor_id: sensor.id,
      value: nil,
      status: "unknown",
      checked_at: timestamp
    }
  end

  defp build_sensor_reading_entry(sensor, {:error, reason}, timestamp) do
    log_sensor_error(sensor, reason)

    %{
      sensor_id: sensor.id,
      value: nil,
      status: "error",
      checked_at: timestamp
    }
  end

  # Process metadata updates and change detection (must remain per-sensor)
  defp handle_sensor_poll_metadata(sensor, {:ok, value}, timestamp) do
    SensorChangeDetector.detect_and_broadcast(sensor, value, timestamp)

    Snmp.update_sensor(sensor, %{
      last_value: value,
      last_checked_at: timestamp
    })
  end

  defp handle_sensor_poll_metadata(_sensor, {:error, _reason}, _timestamp), do: :ok

  # Get state description for state sensors from metadata
  defp get_state_description(sensor, value) do
    if sensor.sensor_type == "state" && is_map(sensor.metadata) do
      states = Map.get(sensor.metadata, "states", %{})
      # Convert value to string for lookup (metadata keys are strings)
      Map.get(states, to_string(trunc(value)), nil)
    end
  end

  defp log_sensor_error(sensor, :no_such_object) do
    Logger.debug("Sensor #{sensor.sensor_descr} OID not found on device")
  end

  defp log_sensor_error(sensor, :no_such_instance) do
    Logger.debug("Sensor #{sensor.sensor_descr} instance not found on device")
  end

  defp log_sensor_error(sensor, :end_of_mib_view) do
    Logger.debug("Sensor #{sensor.sensor_descr} end of MIB view")
  end

  defp log_sensor_error(sensor, reason) do
    Logger.warning("Failed to poll sensor #{sensor.sensor_descr}: #{inspect(reason)}")
  end

  defp poll_simple_sensor(sensor, client_opts) do
    case Client.get(client_opts, sensor.sensor_oid) do
      {:ok, raw_value} ->
        decoded_value = decode_snmp_value(raw_value)

        if is_number(decoded_value) do
          value = Float.round(decoded_value / sensor.sensor_divisor, 1)
          {:ok, value}
        else
          {:error, :non_numeric}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp poll_percentage_sensor(sensor, client_opts) do
    size_oid = sensor.metadata["size_oid"]
    used_oid = sensor.sensor_oid

    with {:ok, used_raw} <- Client.get(client_opts, used_oid),
         {:ok, size_raw} <- Client.get(client_opts, size_oid),
         used when is_number(used) <- decode_snmp_value(used_raw),
         size when is_number(size) <- decode_snmp_value(size_raw),
         true <- size > 0 do
      percentage = used / size * 100

      capped_percentage =
        if percentage > 100 do
          Logger.warning(
            "Percentage sensor #{sensor.sensor_descr} (#{sensor.sensor_type}) " <>
              "exceeded 100%: #{Float.round(percentage, 2)}% " <>
              "(used=#{used} from #{used_oid}, size=#{size} from #{size_oid}). " <>
              "Capping at 100%. This may indicate a device firmware bug."
          )

          100.0
        else
          percentage
        end

      {:ok, capped_percentage}
    else
      {:error, reason} -> {:error, reason}
      false -> {:error, :division_by_zero}
      _ -> {:error, :non_numeric}
    end
  end

  defp poll_interfaces(interfaces, client_opts, timestamp) do
    entries =
      interfaces
      |> Task.async_stream(
        fn interface ->
          {:ok, stat_data} = get_interface_stats(client_opts, interface.if_index)

          Map.merge(stat_data, %{
            interface_id: interface.id,
            checked_at: timestamp
          })
        end,
        max_concurrency: 2,
        timeout: 40_000,
        on_timeout: :kill_task
      )
      |> Enum.flat_map(fn
        {:ok, entry} -> [entry]
        {:exit, _reason} -> []
      end)

    Snmp.create_interface_stats_batch(entries)
  end

  defp check_interface_changes(interfaces, device, client_opts, timestamp) do
    interfaces
    |> Task.async_stream(
      fn interface ->
        case get_interface_attributes(client_opts, interface.if_index) do
          {:ok, current_attrs} ->
            detect_and_log_changes(interface, current_attrs, device.id, timestamp)

          {:error, reason} ->
            Logger.debug("Failed to get interface attributes for #{interface.if_name}: #{inspect(reason)}")
        end
      end,
      max_concurrency: 2,
      timeout: 40_000,
      on_timeout: :kill_task
    )
    |> Stream.run()
  end

  defp get_interface_attributes(client_opts, if_index) do
    oids = [
      "1.3.6.1.2.1.2.2.1.5.#{if_index}",
      "1.3.6.1.2.1.2.2.1.6.#{if_index}",
      "1.3.6.1.2.1.2.2.1.7.#{if_index}",
      "1.3.6.1.2.1.2.2.1.8.#{if_index}"
    ]

    case Client.get_multiple(client_opts, oids) do
      {:ok, [speed, phys_addr, admin_status, oper_status]} ->
        {:ok,
         %{
           if_speed: parse_interface_integer(speed),
           if_phys_address: format_mac_address(phys_addr),
           if_admin_status: parse_if_status(admin_status),
           if_oper_status: parse_if_status(oper_status)
         }}

      {:error, reason} = error ->
        Logger.debug("Failed to get interface attributes: #{inspect(reason)}")
        error
    end
  end

  defp detect_and_log_changes(interface, current_attrs, device_id, timestamp) do
    events =
      []
      |> maybe_add_oper_status_event(interface, current_attrs, device_id, timestamp)
      |> maybe_add_admin_status_event(interface, current_attrs, device_id, timestamp)
      |> maybe_add_speed_change_event(interface, current_attrs, device_id, timestamp)
      |> maybe_add_mac_change_event(interface, current_attrs, device_id, timestamp)

    if events != [] do
      broadcast_interface_events(events)
      Snmp.update_interface(interface, current_attrs)
    end
  end

  defp maybe_add_oper_status_event(events, interface, current_attrs, device_id, timestamp) do
    if interface.if_oper_status == current_attrs.if_oper_status do
      events
    else
      event = build_oper_status_event(interface, current_attrs, device_id, timestamp)
      [{:event, event} | events]
    end
  end

  defp build_oper_status_event(interface, current_attrs, device_id, timestamp) do
    message =
      if interface.if_oper_status do
        "Interface #{interface.if_name} changed from #{String.upcase(interface.if_oper_status)} to #{String.upcase(current_attrs.if_oper_status)}"
      else
        "Interface #{interface.if_name} is now #{String.upcase(current_attrs.if_oper_status)}"
      end

    %{
      device_id: device_id,
      event_type: if(current_attrs.if_oper_status == "up", do: "interface_up", else: "interface_down"),
      severity: if(current_attrs.if_oper_status == "up", do: "info", else: "warning"),
      message: message,
      metadata: %{
        interface_id: interface.id,
        interface_name: interface.if_name,
        old_status: interface.if_oper_status,
        new_status: current_attrs.if_oper_status
      },
      occurred_at: timestamp
    }
  end

  defp maybe_add_admin_status_event(events, interface, current_attrs, device_id, timestamp) do
    if interface.if_admin_status == current_attrs.if_admin_status do
      events
    else
      event = build_admin_status_event(interface, current_attrs, device_id, timestamp)
      [{:event, event} | events]
    end
  end

  defp build_admin_status_event(interface, current_attrs, device_id, timestamp) do
    %{
      device_id: device_id,
      event_type: "interface_admin_status_change",
      severity: "info",
      message:
        "Interface #{interface.if_name} admin status changed from #{interface.if_admin_status} to #{current_attrs.if_admin_status}",
      metadata: %{
        interface_id: interface.id,
        interface_name: interface.if_name,
        old_status: interface.if_admin_status,
        new_status: current_attrs.if_admin_status
      },
      occurred_at: timestamp
    }
  end

  defp maybe_add_speed_change_event(events, interface, current_attrs, device_id, timestamp) do
    if interface.if_speed != current_attrs.if_speed && current_attrs.if_speed != nil do
      event = build_speed_change_event(interface, current_attrs, device_id, timestamp)
      [{:event, event} | events]
    else
      events
    end
  end

  defp build_speed_change_event(interface, current_attrs, device_id, timestamp) do
    is_initial = interface.if_speed == nil
    severity = determine_speed_change_severity(is_initial, interface.if_speed, current_attrs.if_speed)
    message = format_speed_change_message(interface.if_name, is_initial, interface.if_speed, current_attrs.if_speed)

    %{
      device_id: device_id,
      event_type: "interface_speed_change",
      severity: severity,
      message: message,
      metadata: %{
        interface_id: interface.id,
        interface_name: interface.if_name,
        old_speed: interface.if_speed,
        new_speed: current_attrs.if_speed
      },
      occurred_at: timestamp
    }
  end

  defp determine_speed_change_severity(true, _old_speed, _new_speed), do: "info"
  defp determine_speed_change_severity(false, old_speed, new_speed) when new_speed < old_speed, do: "warning"
  defp determine_speed_change_severity(false, _old_speed, _new_speed), do: "info"

  defp format_speed_change_message(if_name, true, _old_speed, new_speed) do
    "Interface #{if_name} speed detected: #{format_speed(new_speed)}"
  end

  defp format_speed_change_message(if_name, false, old_speed, new_speed) do
    "Interface #{if_name} speed changed from #{format_speed(old_speed)} to #{format_speed(new_speed)}"
  end

  defp maybe_add_mac_change_event(events, interface, current_attrs, device_id, timestamp) do
    if interface.if_phys_address != current_attrs.if_phys_address && current_attrs.if_phys_address != nil do
      event = build_mac_change_event(interface, current_attrs, device_id, timestamp)
      [{:event, event} | events]
    else
      events
    end
  end

  defp build_mac_change_event(interface, current_attrs, device_id, timestamp) do
    is_initial = interface.if_phys_address == nil

    message =
      format_mac_change_message(interface.if_name, is_initial, interface.if_phys_address, current_attrs.if_phys_address)

    %{
      device_id: device_id,
      event_type: "interface_mac_change",
      severity: if(is_initial, do: "info", else: "warning"),
      message: message,
      metadata: %{
        interface_id: interface.id,
        interface_name: interface.if_name,
        old_mac: interface.if_phys_address,
        new_mac: current_attrs.if_phys_address
      },
      occurred_at: timestamp
    }
  end

  defp format_mac_change_message(if_name, true, _old_mac, new_mac) do
    "Interface #{if_name} MAC address detected: #{new_mac}"
  end

  defp format_mac_change_message(if_name, false, old_mac, new_mac) do
    "Interface #{if_name} MAC address changed from #{old_mac} to #{new_mac}"
  end

  defp broadcast_interface_events(events) do
    Enum.each(events, fn {:event, event_attrs} ->
      _ =
        Phoenix.PubSub.broadcast(
          Towerops.PubSub,
          "device:#{event_attrs.device_id}",
          {:device_event, event_attrs}
        )

      _ =
        Phoenix.PubSub.broadcast(
          Towerops.PubSub,
          "device:events",
          {:device_event, event_attrs}
        )

      Logger.debug("Broadcast event: #{event_attrs.message}")
    end)
  end

  defp parse_interface_integer(value) when is_integer(value), do: value
  defp parse_interface_integer(_), do: nil

  defp parse_if_status(1), do: "up"
  defp parse_if_status(2), do: "down"
  defp parse_if_status(3), do: "testing"
  defp parse_if_status(_), do: "unknown"

  defp format_mac_address(<<>>) when is_binary(<<>>), do: nil
  defp format_mac_address(nil), do: nil

  defp format_mac_address(mac) when is_binary(mac) do
    mac
    |> :binary.bin_to_list()
    |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
    |> String.downcase()
  end

  defp format_mac_address(_), do: nil

  defp format_speed(speed_bps) when is_integer(speed_bps) do
    cond do
      speed_bps >= 1_000_000_000 ->
        "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"

      speed_bps >= 1_000_000 ->
        "#{Float.round(speed_bps / 1_000_000, 1)} Mbps"

      speed_bps >= 1_000 ->
        "#{Float.round(speed_bps / 1_000, 1)} Kbps"

      true ->
        "#{speed_bps} bps"
    end
  end

  defp format_speed(_), do: "Unknown"

  defp get_interface_stats(client_opts, if_index) do
    hc_oids = [
      if_in_octets: "1.3.6.1.2.1.31.1.1.1.6.#{if_index}",
      if_out_octets: "1.3.6.1.2.1.31.1.1.1.10.#{if_index}"
    ]

    std_oids = [
      if_in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}",
      if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}"
    ]

    error_oids = [
      if_in_errors: "1.3.6.1.2.1.2.2.1.14.#{if_index}",
      if_out_errors: "1.3.6.1.2.1.2.2.1.20.#{if_index}",
      if_in_discards: "1.3.6.1.2.1.2.2.1.13.#{if_index}",
      if_out_discards: "1.3.6.1.2.1.2.2.1.19.#{if_index}"
    ]

    octet_results = fetch_octet_counters(client_opts, hc_oids, std_oids)

    error_results =
      Enum.map(error_oids, fn {key, oid} ->
        case Client.get(client_opts, oid) do
          {:ok, value} -> {key, decode_snmp_value(value)}
          _ -> {key, nil}
        end
      end)

    {:ok, Map.new(octet_results ++ error_results)}
  end

  defp fetch_octet_counters(client_opts, hc_oids, std_oids) do
    hc_results = Enum.map(hc_oids, &try_fetch_hc_counter(client_opts, &1))

    hc_available = Enum.all?(hc_results, fn {_key, value} -> value != :not_available end)

    if hc_available do
      Enum.map(hc_results, fn {key, value} -> {key, value} end)
    else
      Logger.debug("HC counters not available, using standard 32-bit counters")
      Enum.map(std_oids, &fetch_standard_counter(client_opts, &1))
    end
  end

  defp try_fetch_hc_counter(client_opts, {key, oid}) do
    case Client.get(client_opts, oid) do
      {:ok, value} ->
        decoded = decode_snmp_value(value)
        if decoded == nil, do: {key, :not_available}, else: {key, decoded}

      _ ->
        {key, :not_available}
    end
  end

  defp fetch_standard_counter(client_opts, {key, oid}) do
    case Client.get(client_opts, oid) do
      {:ok, value} -> {key, decode_snmp_value(value)}
      _ -> {key, nil}
    end
  end

  defp decode_snmp_value(value) when is_number(value) do
    value
  end

  defp decode_snmp_value(value) when is_binary(value) do
    size = byte_size(value)

    result =
      case size do
        8 ->
          <<counter::unsigned-big-integer-size(64)>> = value
          counter

        16 ->
          <<_prefix::binary-size(8), counter::unsigned-big-integer-size(64)>> = value
          counter

        s when s > 8 ->
          offset = s - 8
          <<_prefix::binary-size(^offset), counter::unsigned-big-integer-size(64)>> = value
          counter

        _ ->
          Logger.warning("Unknown SNMP binary value format, size: #{size}")
          nil
      end

    result
  rescue
    error ->
      Logger.error("Failed to decode SNMP binary value (size: #{byte_size(value)}): #{inspect(error)}")
      nil
  end

  defp decode_snmp_value(other) do
    Logger.warning("Unexpected SNMP value type: #{inspect(other)}")
    nil
  end

  defp build_client_opts(device) do
    snmp_config = Devices.get_snmp_config(device)

    base_opts = [
      ip: device.ip_address,
      version: snmp_config.version,
      port: device.snmp_port || 161
    ]

    # Add version-specific credentials
    if snmp_config.version == "3" do
      v3_config = Devices.get_snmpv3_config(device)

      base_opts ++
        [
          security_name: v3_config.username,
          security_level: v3_config.security_level,
          auth_protocol: v3_config.auth_protocol,
          auth_password: v3_config.auth_password,
          priv_protocol: v3_config.priv_protocol,
          priv_password: v3_config.priv_password
        ]
    else
      base_opts ++ [community: snmp_config.community]
    end
  end

  defp get_poll_interval(device) do
    min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300)
    max(device.check_interval_seconds || @default_poll_interval, min_interval)
  end

  defp schedule_next_poll(device_id, interval_seconds) do
    # Use same offset calculation as start_polling to maintain consistent intervals
    # Offset is deterministic hash-based value from 0 to interval_seconds
    # This spreads polls evenly across the interval window
    offset = PollingOffset.calculate_offset(device_id, interval_seconds)

    %{device_id: device_id}
    |> new(schedule_in: offset)
    |> Oban.insert()
  end

  defp should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do
    case device.last_snmp_poll_at do
      nil ->
        false

      last_poll ->
        now = DateTime.utc_now()
        seconds_since_poll = DateTime.diff(now, last_poll, :second)
        min_interval = poll_interval_seconds - grace_period_seconds

        seconds_since_poll < min_interval
    end
  end
end
