defmodule Towerops.Snmp do
  @moduledoc """
  The SNMP context.

  Provides the public API for SNMP discovery and monitoring functionality.
  """

  import Ecto.Query

  alias Towerops.Devices.Device, as: DeviceSchema
  alias Towerops.Repo
  alias Towerops.Snmp.Client
  alias Towerops.Snmp.Device
  alias Towerops.Snmp.Discovery
  alias Towerops.Snmp.Interface
  alias Towerops.Snmp.InterfaceStat
  alias Towerops.Snmp.Neighbor
  alias Towerops.Snmp.Sensor
  alias Towerops.Snmp.SensorReading

  @doc """
  Tests SNMP connectivity to a device.

  ## Examples

      iex> test_connection("192.168.1.1", "public", "2c")
      {:ok, "Connection successful"}

      iex> test_connection("192.168.1.99", "wrong", "2c")
      {:error, :timeout}
  """
  def test_connection(ip, community, version, port \\ 161) do
    Client.test_connection(
      ip: ip,
      community: community,
      version: version,
      port: port
    )
  end

  @doc """
  Runs SNMP discovery for a piece of device.

  Discovers:
  - Device information (manufacturer, model, firmware)
  - Network interfaces
  - Sensors (temperature, power, fans, etc.)

  Updates the database with discovered data.

  ## Examples

      iex> discover_device(device)
      {:ok, %Device{}}
  """
  def discover_device(%DeviceSchema{} = device) do
    Discovery.discover_device(device)
  end

  @doc """
  Runs SNMP discovery for all SNMP-enabled devices in an organization.

  Returns a summary of successful and failed discoveries.

  ## Examples

      iex> discover_all_for_org(org_id)
      {:ok, %{success: 10, failed: 2, errors: [:timeout, :no_response]}}
  """
  def discover_all_for_org(org_id) do
    Discovery.discover_all(org_id)
  end

  # Device queries

  @doc """
  Gets the SNMP device for a piece of device.

  ## Examples

      iex> get_device(device_id)
      %Device{}

      iex> get_device(nonexistent_id)
      nil
  """
  def get_device(device_id) do
    Repo.get_by(Device, device_id: device_id)
  end

  @doc """
  Gets the SNMP device for a device with preloaded associations.
  """
  def get_device_with_associations(device_id) do
    Device
    |> where([d], d.device_id == ^device_id)
    |> preload([:interfaces, :sensors])
    |> Repo.one()
  end

  # Interface queries

  @doc """
  Lists all interfaces for a device.
  """
  def list_interfaces(device_id) do
    Interface
    |> where([i], i.snmp_device_id == ^device_id)
    |> order_by([i], i.if_index)
    |> Repo.all()
  end

  @doc """
  Lists only monitored interfaces for a device.
  """
  def list_monitored_interfaces(device_id) do
    Interface
    |> where([i], i.snmp_device_id == ^device_id and i.monitored == true)
    |> order_by([i], i.if_index)
    |> Repo.all()
  end

  @doc """
  Gets a specific interface.
  """
  def get_interface(interface_id) do
    Repo.get(Interface, interface_id)
  end

  @doc """
  Updates an interface.
  """
  def update_interface(%Interface{} = interface, attrs) do
    interface
    |> Interface.changeset(attrs)
    |> Repo.update()
  end

  # Sensor queries

  @doc """
  Lists all sensors for a device.
  """
  def list_sensors(device_id) do
    Sensor
    |> where([s], s.snmp_device_id == ^device_id)
    |> order_by([s], [s.sensor_type, s.sensor_index])
    |> Repo.all()
  end

  @doc """
  Lists only monitored sensors for a device.
  """
  def list_monitored_sensors(device_id) do
    Sensor
    |> where([s], s.snmp_device_id == ^device_id and s.monitored == true)
    |> order_by([s], [s.sensor_type, s.sensor_index])
    |> Repo.all()
  end

  @doc """
  Lists sensors grouped by type.
  """
  def list_sensors_by_type(device_id) do
    sensors =
      Sensor
      |> where([s], s.snmp_device_id == ^device_id)
      |> order_by([s], [s.sensor_type, s.sensor_index])
      |> Repo.all()

    Enum.group_by(sensors, & &1.sensor_type)
  end

  @doc """
  Gets a specific sensor.
  """
  def get_sensor(sensor_id) do
    Repo.get(Sensor, sensor_id)
  end

  @doc """
  Updates a sensor.
  """
  def update_sensor(%Sensor{} = sensor, attrs) do
    sensor
    |> Sensor.changeset(attrs)
    |> Repo.update()
  end

  # Sensor readings queries

  @doc """
  Gets recent sensor readings for a sensor.

  ## Options

  - `:limit` - Maximum number of readings to return (default: 100)
  - `:since` - Only return readings after this datetime
  """
  def get_sensor_readings(sensor_id, opts \\ []) do
    limit = Keyword.get(opts, :limit, 100)
    since = Keyword.get(opts, :since)

    query =
      SensorReading
      |> where([r], r.sensor_id == ^sensor_id)
      |> order_by([r], desc: r.checked_at)
      |> limit(^limit)

    query =
      if since do
        where(query, [r], r.checked_at >= ^since)
      else
        query
      end

    Repo.all(query)
  end

  @doc """
  Gets the latest sensor reading for a sensor.
  """
  def get_latest_sensor_reading(sensor_id) do
    SensorReading
    |> where([r], r.sensor_id == ^sensor_id)
    |> order_by([r], desc: r.checked_at)
    |> limit(1)
    |> Repo.one()
  end

  @doc """
  Gets the latest sensor readings for multiple sensors in a single query.

  Returns a map of %{sensor_id => reading} for efficient batch loading.
  Sensors without readings will not be present in the map.

  ## Examples

      iex> get_latest_sensor_readings_batch([sensor_id1, sensor_id2])
      %{sensor_id1 => %SensorReading{}, sensor_id2 => %SensorReading{}}
  """
  def get_latest_sensor_readings_batch(sensor_ids) when is_list(sensor_ids) do
    if Enum.empty?(sensor_ids) do
      %{}
    else
      # Use DISTINCT ON to get only the latest reading per sensor
      query =
        from(r in SensorReading,
          where: r.sensor_id in ^sensor_ids,
          distinct: r.sensor_id,
          order_by: [asc: r.sensor_id, desc: r.checked_at]
        )

      query
      |> Repo.all()
      |> Map.new(fn reading -> {reading.sensor_id, reading} end)
    end
  end

  # Interface stats queries

  @doc """
  Gets recent interface statistics for an interface.

  ## Options

  - `:limit` - Maximum number of stats to return (default: 100)
  - `:since` - Only return stats after this datetime
  """
  def get_interface_stats(interface_id, opts \\ []) do
    limit = Keyword.get(opts, :limit, 100)
    since = Keyword.get(opts, :since)

    query =
      InterfaceStat
      |> where([s], s.interface_id == ^interface_id)
      |> order_by([s], desc: s.checked_at)
      |> limit(^limit)

    query =
      if since do
        where(query, [s], s.checked_at >= ^since)
      else
        query
      end

    Repo.all(query)
  end

  @doc """
  Gets the latest interface stat for an interface.
  """
  def get_latest_interface_stat(interface_id) do
    InterfaceStat
    |> where([s], s.interface_id == ^interface_id)
    |> order_by([s], desc: s.checked_at)
    |> limit(1)
    |> Repo.one()
  end

  @doc """
  Gets the latest interface stats for multiple interfaces in a single query.

  Returns a map of %{interface_id => stat} for efficient batch loading.
  Interfaces without stats will not be present in the map.

  ## Examples

      iex> get_latest_interface_stats_batch([if_id1, if_id2])
      %{if_id1 => %InterfaceStat{}, if_id2 => %InterfaceStat{}}
  """
  def get_latest_interface_stats_batch(interface_ids) when is_list(interface_ids) do
    if Enum.empty?(interface_ids) do
      %{}
    else
      # Use DISTINCT ON to get only the latest stat per interface
      query =
        from(s in InterfaceStat,
          where: s.interface_id in ^interface_ids,
          distinct: s.interface_id,
          order_by: [asc: s.interface_id, desc: s.checked_at]
        )

      query
      |> Repo.all()
      |> Map.new(fn stat -> {stat.interface_id, stat} end)
    end
  end

  @doc """
  Records a new sensor reading.
  """
  def create_sensor_reading(attrs) do
    %SensorReading{}
    |> SensorReading.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Records a new interface stat.
  """
  def create_interface_stat(attrs) do
    %InterfaceStat{}
    |> InterfaceStat.changeset(attrs)
    |> Repo.insert()
  end

  # Neighbor queries

  @doc """
  Lists all neighbors for a piece of device.
  """
  def list_neighbors(device_id) do
    Neighbor
    |> where([n], n.device_id == ^device_id)
    |> preload(:interface)
    |> order_by([n], [n.protocol, n.remote_system_name])
    |> Repo.all()
  end

  @doc """
  Lists all neighbors for device with enriched data linking to known device.
  Adds a `matched_equipment` field to each neighbor if we can identify the remote device.
  """
  def list_neighbors_with_equipment(device_id) do
    neighbors = list_neighbors(device_id)

    # device to search within the same org
    device = Towerops.Devices.get_device!(device_id)

    Enum.map(neighbors, fn neighbor ->
      matched_device = find_matching_equipment(neighbor, device.site.organization_id)
      Map.put(neighbor, :matched_device, matched_device)
    end)
  end

  # device in the organization
  defp find_matching_equipment(neighbor, organization_id) do
    # Try matching strategies in order: chassis ID, port ID, system name
    try_match_by_chassis(neighbor, organization_id) ||
      try_match_by_port(neighbor, organization_id) ||
      try_match_by_name(neighbor, organization_id)
  end

  defp try_match_by_chassis(neighbor, organization_id) do
    if neighbor.remote_chassis_id && mac_address?(neighbor.remote_chassis_id) do
      find_device_by_mac(neighbor.remote_chassis_id, organization_id)
    end
  end

  defp try_match_by_port(neighbor, organization_id) do
    if neighbor.remote_port_id && mac_address?(neighbor.remote_port_id) do
      find_device_by_mac(neighbor.remote_port_id, organization_id)
    end
  end

  defp try_match_by_name(neighbor, organization_id) do
    if neighbor.remote_system_name do
      find_device_by_name(neighbor.remote_system_name, organization_id)
    end
  end

  # Check if a string looks like a MAC address
  defp mac_address?(str) when is_binary(str) do
    # Match patterns like "AA:BB:CC:DD:EE:FF" or "aa-bb-cc-dd-ee-ff"
    String.match?(str, ~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/)
  end

  defp mac_address?(_), do: false

  defp find_device_by_mac(mac_address, organization_id) do
    # MAC address might be in format "aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff", or "aabbccddeeff"
    # Normalize to just the hex digits for comparison
    normalized_mac = mac_address |> String.downcase() |> String.replace(~r/[:-]/, "")

    Repo.one(
      from(i in Interface,
        join: d in Device,
        on: i.snmp_device_id == d.id,
        join: e in DeviceSchema,
        on: d.device_id == e.id,
        join: s in assoc(e, :site),
        where: s.organization_id == ^organization_id,
        where:
          fragment("REPLACE(REPLACE(LOWER(?), ':', ''), '-', '')", i.if_phys_address) ==
            ^normalized_mac,
        select: e,
        limit: 1
      )
    )
  end

  defp find_device_by_name(system_name, organization_id) do
    # Try exact match first, then partial match
    normalized_name = String.downcase(system_name)

    from(e in DeviceSchema,
      join: s in assoc(e, :site),
      where: s.organization_id == ^organization_id,
      where: fragment("LOWER(?)", e.name) == ^normalized_name,
      select: e,
      limit: 1
    )
    |> Repo.one()
    |> case do
      nil ->
        # Try partial match as fallback
        Repo.one(
          from(e in DeviceSchema,
            join: s in assoc(e, :site),
            where: s.organization_id == ^organization_id,
            where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),
            select: e,
            limit: 1
          )
        )

      device ->
        device
    end
  end

  @doc """
  Gets a specific neighbor.
  """
  def get_neighbor(neighbor_id) do
    Repo.get(Neighbor, neighbor_id)
  end

  @doc """
  Creates or updates a neighbor record.
  """
  def upsert_neighbor(attrs) do
    case Repo.get_by(Neighbor,
           interface_id: attrs.interface_id,
           remote_chassis_id: attrs[:remote_chassis_id],
           protocol: attrs.protocol
         ) do
      nil ->
        %Neighbor{}
        |> Neighbor.changeset(attrs)
        |> Repo.insert()

      neighbor ->
        neighbor
        |> Neighbor.changeset(attrs)
        |> Repo.update()
    end
  end

  @doc """
  Deletes stale neighbors that haven't been seen since the given datetime.
  """
  def delete_stale_neighbors(device_id, cutoff_datetime) do
    Neighbor
    |> where([n], n.device_id == ^device_id)
    |> where([n], n.last_discovered_at < ^cutoff_datetime)
    |> Repo.delete_all()
  end
end
