defmodule Towerops.Snmp.Discovery do
  @moduledoc """
  Core SNMP discovery orchestrator.

  Handles the complete discovery workflow:
  1. Test SNMP connectivity
  2. Discover system information
  3. Select appropriate device profile (Cisco, NetSNMP, or Base)
  4. Discover interfaces and sensors using profile
  5. Save discovered data to database

  Can be run manually or scheduled as a background job.
  """

  import Ecto.Query

  alias Towerops.Devices
  alias Towerops.Devices.Device, as: DeviceSchema
  alias Towerops.Repo
  alias Towerops.Snmp.Client
  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Interface
  alias Towerops.Snmp.NeighborDiscovery
  alias Towerops.Snmp.Profiles.Base
  alias Towerops.Snmp.Profiles.Cisco
  alias Towerops.Snmp.Profiles.Mikrotik
  alias Towerops.Snmp.Profiles.NetSnmp
  alias Towerops.Snmp.Profiles.Ubiquiti
  alias Towerops.Snmp.Sensor

  require Logger

  @type system_info :: %{
          optional(:sys_descr) => String.t(),
          optional(:sys_object_id) => String.t(),
          optional(:sys_name) => String.t(),
          optional(:sys_uptime) => non_neg_integer(),
          optional(:sys_contact) => String.t(),
          optional(:sys_location) => String.t(),
          optional(atom()) => term()
        }

  @type device_info :: %{
          optional(:manufacturer) => String.t(),
          optional(:model) => String.t(),
          optional(:firmware_version) => String.t() | nil,
          optional(:serial_number) => String.t() | nil,
          optional(atom()) => term()
        }

  @type interface_data :: %{
          required(:if_index) => non_neg_integer(),
          required(:if_descr) => String.t(),
          optional(:if_name) => String.t() | nil,
          optional(:if_alias) => String.t() | nil,
          optional(:if_type) => non_neg_integer() | nil,
          optional(:if_mtu) => non_neg_integer() | nil,
          optional(:if_speed) => non_neg_integer() | nil,
          optional(:if_phys_address) => String.t() | nil,
          optional(:if_admin_status) => non_neg_integer() | nil,
          optional(:if_oper_status) => non_neg_integer() | nil,
          optional(atom()) => term()
        }

  @type sensor_data :: %{
          required(:sensor_type) => String.t(),
          required(:sensor_index) => String.t(),
          required(:sensor_oid) => String.t(),
          required(:sensor_descr) => String.t(),
          required(:sensor_unit) => String.t(),
          required(:sensor_divisor) => number(),
          optional(:last_value) => float() | nil,
          optional(:status) => String.t(),
          optional(atom()) => term()
        }

  @type profile :: module()

  @type discovery_summary :: %{
          success: non_neg_integer(),
          failed: non_neg_integer(),
          errors: [term()]
        }

  @doc """
  Runs discovery for a single device.
  Returns {:ok, device} or {:error, reason}.

  ## Examples

      iex> discover_device(device)
      {:ok, %Device{manufacturer: "Cisco", model: "C2960"}}

      iex> discover_device(device_without_snmp)
      {:error, :snmp_not_enabled}
  """
  @spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
  def discover_device(%DeviceSchema{} = device) do
    if device.snmp_enabled do
      Logger.info("Starting SNMP discovery for device: #{device.name} (#{device.ip_address})")

      client_opts = build_client_opts(device)

      with {:ok, _} <- Client.test_connection(client_opts),
           {:ok, system_info} <- discover_system(client_opts),
           {:ok, device} <- update_device_name_from_snmp(device, system_info),
           profile = select_profile(system_info),
           {:ok, device_info} <- build_device_info(client_opts, system_info, profile),
           {:ok, interfaces} <- discover_interfaces(client_opts, profile),
           {:ok, sensors} <- discover_sensors(client_opts, profile),
           {:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors),
           {:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces),
           :ok <- save_neighbors(discovered_device.device_id, neighbors) do
        _ = update_device_discovery_time(device)
        Logger.info("SNMP discovery completed successfully for: #{device.name}")

        # Broadcast discovery completion for real-time updates
        _ =
          Phoenix.PubSub.broadcast(
            Towerops.PubSub,
            "device:#{device.id}",
            {:discovery_completed, device.id}
          )

        {:ok, discovered_device}
      else
        {:error, reason} = error ->
          Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
          error
      end
    else
      {:error, :snmp_not_enabled}
    end
  end

  @doc """
  Runs discovery for all SNMP-enabled devices in an organization.
  Returns a summary of successful and failed discoveries.
  """
  @spec discover_all(String.t()) :: {:ok, discovery_summary()}
  def discover_all(org_id) do
    device_list =
      DeviceSchema
      |> join(:inner, [e], s in assoc(e, :site))
      |> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
      |> Repo.all()

    Logger.info("Starting SNMP discovery for #{length(device_list)} devices in org #{org_id}")

    results =
      device_list
      |> Task.async_stream(
        &discover_device/1,
        max_concurrency: 5,
        timeout: 60_000,
        on_timeout: :kill_task
      )
      |> Enum.reduce(%{success: 0, failed: 0, errors: []}, fn
        {:ok, {:ok, _device}}, acc ->
          %{acc | success: acc.success + 1}

        {:ok, {:error, reason}}, acc ->
          %{acc | failed: acc.failed + 1, errors: [reason | acc.errors]}

        {:exit, :timeout}, acc ->
          %{acc | failed: acc.failed + 1, errors: [:timeout | acc.errors]}
      end)

    Logger.info("Discovery completed: #{results.success} succeeded, #{results.failed} failed")
    {:ok, results}
  end

  # Private functions

  @spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
  defp build_client_opts(device) do
    # Get SNMP config with hierarchical fallback (device -> site -> organization)
    snmp_config = Devices.get_snmp_config(device)

    [
      ip: device.ip_address,
      community: snmp_config.community,
      version: snmp_config.version,
      port: device.snmp_port || 161
    ]
  end

  @spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
  defp discover_system(client_opts) do
    # Use Base profile for initial system discovery
    Base.discover_system_info(client_opts)
  end

  @doc """
  Selects the appropriate SNMP profile based on system information.

  First tries to match against database-stored device profiles.
  If no database match is found, falls back to hard-coded profile modules.

  ## Examples

      iex> select_profile(%{sys_descr: "Cisco IOS"})
      Towerops.Snmp.Profiles.Cisco

      iex> select_profile(%{sys_object_id: "1.3.6.1.4.1.41112"})
      Towerops.Snmp.Profiles.Ubiquiti
  """
  @spec select_profile(system_info()) :: profile()
  def select_profile(system_info) do
    sys_descr = Map.get(system_info, :sys_descr, "")
    sys_object_id = Map.get(system_info, :sys_object_id, "")

    cond do
      String.contains?(sys_descr, "RouterOS") ->
        Logger.debug("Selected MikroTik profile")
        Mikrotik

      String.contains?(sys_descr, ["Cisco", "IOS"]) ->
        Logger.debug("Selected Cisco profile")
        Cisco

      # Ubiquiti devices (check before generic Linux since they run Linux)
      String.contains?(sys_object_id, "41112") or
          String.contains?(sys_descr, ["airOS", "AirFiber", "airMAX", "Ubiquiti"]) ->
        Logger.debug("Selected Ubiquiti profile")
        Ubiquiti

      String.contains?(sys_descr, "Linux") ->
        Logger.debug("Selected NetSNMP profile")
        NetSnmp

      true ->
        Logger.debug("Selected Base profile")
        Base
    end
  end

  @spec build_device_info(Client.connection_opts(), system_info(), profile()) ::
          {:ok, device_info()}
  defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do
    # Let the hard-coded profile identify the device
    identified_info = profile.identify_device(system_info)
    {:ok, identified_info}
  rescue
    _error ->
      Logger.error("Failed to identify device")
      {:ok, system_info}
  end

  @spec discover_interfaces(Client.connection_opts(), profile()) ::
          {:ok, [interface_data()]}
  defp discover_interfaces(client_opts, profile) when is_atom(profile) do
    case profile.discover_interfaces(client_opts) do
      {:ok, interfaces} ->
        Logger.debug("Discovered #{length(interfaces)} interfaces")
        {:ok, interfaces}

      {:error, _} ->
        Logger.warning("Interface discovery failed, continuing without interfaces")
        {:ok, []}
    end
  end

  @spec discover_sensors(Client.connection_opts(), profile()) :: {:ok, [sensor_data()]}
  defp discover_sensors(client_opts, profile) when is_atom(profile) do
    case profile.discover_sensors(client_opts) do
      {:ok, sensors} ->
        Logger.debug("Discovered #{length(sensors)} sensors")
        {:ok, sensors}

      {:error, _} ->
        Logger.warning("Sensor discovery failed, continuing without sensors")
        {:ok, []}
    end
  end

  @spec save_discovery_results(
          DeviceSchema.t(),
          device_info(),
          [interface_data()],
          [sensor_data()]
        ) :: {:ok, Device.t()} | {:error, term()}
  defp save_discovery_results(device, device_info, interfaces, sensors) do
    Repo.transaction(fn ->
      # Upsert Device
      snmp_device = upsert_device(device, device_info)

      # Delete old interfaces/sensors and insert new ones
      _ = delete_old_data(snmp_device)
      new_interfaces = insert_interfaces(snmp_device, interfaces)
      _ = insert_sensors(snmp_device, sensors)

      # Return device with interfaces loaded and device_id added
      # Use snmp_device.device_id which references the Equipment table
      %{snmp_device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :device_id, snmp_device.device_id))}
    end)
  end

  @spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t()
  defp upsert_device(device, device_info) do
    case Repo.get_by(Device, device_id: device.id) do
      nil ->
        %Device{}
        |> Device.changeset(Map.put(device_info, :device_id, device.id))
        |> Repo.insert!()

      existing_device ->
        existing_device
        |> Device.changeset(device_info)
        |> Repo.update!()
    end
  end

  @spec delete_old_data(Device.t()) :: {non_neg_integer(), nil | [term()]}
  defp delete_old_data(device) do
    # Delete old interfaces and sensors (cascade will handle stats/readings)
    Repo.delete_all(from i in Interface, where: i.snmp_device_id == ^device.id)
    Repo.delete_all(from s in Sensor, where: s.snmp_device_id == ^device.id)
  end

  @spec insert_interfaces(Device.t(), [interface_data()]) :: [Interface.t()]
  defp insert_interfaces(device, interfaces) do
    Enum.map(interfaces, fn interface_data ->
      %Interface{}
      |> Interface.changeset(Map.put(interface_data, :snmp_device_id, device.id))
      |> Repo.insert!()
    end)
  end

  @spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
  defp insert_sensors(device, sensors) do
    # Deduplicate sensors by sensor_index (keep first occurrence)
    # This prevents duplicate key errors when profiles generate duplicate indices
    sensors
    |> Enum.uniq_by(& &1.sensor_index)
    |> Enum.map(fn sensor_data ->
      %Sensor{}
      |> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
      |> Repo.insert!()
    end)
  end

  @spec update_device_discovery_time(DeviceSchema.t()) ::
          {:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
  defp update_device_discovery_time(device) do
    device
    |> Ecto.Changeset.change(last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second))
    |> Repo.update()
  end

  @spec save_neighbors(binary(), [map()]) :: :ok
  defp save_neighbors(device_id, neighbors) do
    # Delete stale neighbors (not seen in last 5 minutes)
    cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
    Towerops.Snmp.delete_stale_neighbors(device_id, cutoff)

    # Upsert each discovered neighbor
    Enum.each(neighbors, fn neighbor_data ->
      Towerops.Snmp.upsert_neighbor(neighbor_data)
    end)

    Logger.info("Saved #{length(neighbors)} neighbors for device #{device_id}")
    :ok
  end

  @spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
          {:ok, DeviceSchema.t()} | {:error, term()}
  defp update_device_name_from_snmp(device, system_info) do
    # Only update device name if it's currently empty and we got a sysName from SNMP
    if (is_nil(device.name) or device.name == "") and Map.has_key?(system_info, :sys_name) do
      sys_name = Map.get(system_info, :sys_name)

      if sys_name && sys_name != "" do
        Logger.info("Updating device name from SNMP sysName: #{sys_name}")

        device
        |> Ecto.Changeset.change(name: sys_name)
        |> Repo.update()
      else
        {:ok, device}
      end
    else
      {:ok, device}
    end
  end
end
