defmodule ToweropsWeb.Api.V1.DevicesController do
  @moduledoc """
  API controller for managing devices.

  All endpoints require API token authentication and operations are scoped
  to the organization associated with the token.
  """
  use ToweropsWeb, :controller

  alias Towerops.Devices
  alias Towerops.Organizations
  alias Towerops.Workers.DiscoveryWorker

  @doc """
  GET /api/v1/devices

  Lists all devices for the authenticated organization.

  Query params:
  - site_id: Filter by site (optional)

  Response:
  {
    "devices": [
      {
        "id": "uuid",
        "name": "Core Router",
        "ip_address": "192.168.1.1",
        "site_id": "uuid",
        "monitoring_enabled": true,
        "snmp_enabled": true,
        "inserted_at": "2026-01-15T19:44:25Z"
      }
    ]
  }
  """
  def index(conn, params) do
    organization_id = conn.assigns.current_organization_id

    devices =
      organization_id
      |> Devices.list_organization_devices(params)
      |> Enum.map(&format_device/1)

    json(conn, %{devices: devices})
  end

  @doc """
  POST /api/v1/devices

  Creates a new device. The device can be assigned to a site or directly to the organization.

  Request body:
  {
    "device": {
      "site_id": "uuid",  # optional - if not provided, device is assigned directly to organization
      "organization_id": "uuid",  # optional - defaults to authenticated organization if not provided
      "name": "Core Router",
      "ip_address": "192.168.1.1",
      "description": "Main router",  # optional
      "monitoring_enabled": true,  # optional, default true
      "snmp_enabled": true,  # optional, default true
      "snmp_version": "2c",  # optional, default "2c" (can be "1", "2c", or "3")
      "snmp_community": "public",  # optional, inherits from site/org if not set
      "snmp_port": 161,  # optional, default 161
      # SNMPv3 fields (only used when snmp_version is "3"):
      "snmpv3_security_level": "authPriv",  # optional, one of: noAuthNoPriv, authNoPriv, authPriv
      "snmpv3_username": "snmpuser",  # optional
      "snmpv3_auth_protocol": "SHA-256",  # optional, one of: MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512
      "snmpv3_auth_password": "authpass",  # optional
      "snmpv3_priv_protocol": "AES",  # optional, one of: DES, AES, AES-192, AES-256
      "snmpv3_priv_password": "privpass"  # optional
    }
  }

  Response:
  {
    "id": "uuid",
    "name": "Core Router",
    "ip_address": "192.168.1.1",
    "site_id": "uuid",
    "organization_id": "uuid",
    "monitoring_enabled": true,
    "snmp_enabled": true,
    "inserted_at": "2026-01-15T19:44:25Z"
  }
  """
  def create(conn, %{"device" => device_params}) do
    organization_id = conn.assigns.current_organization_id
    current_user = conn.assigns[:current_user]

    device_params =
      device_params
      |> default_organization_id(organization_id)
      |> maybe_strip_site_id_if_sites_disabled(organization_id)

    case verify_site_access(device_params["site_id"], organization_id) do
      :ok ->
        create_and_discover_device(conn, device_params, current_user)

      {:error, reason} ->
        conn
        |> put_status(:forbidden)
        |> json(%{error: reason})
    end
  end

  def create(conn, _params) do
    conn
    |> put_status(:bad_request)
    |> json(%{error: "Missing 'device' parameter"})
  end

  @doc """
  GET /api/v1/devices/:id

  Gets a single device by ID.

  Response:
  {
    "id": "uuid",
    "name": "Core Router",
    "ip_address": "192.168.1.1",
    "site_id": "uuid",
    "description": "Main router",
    "monitoring_enabled": true,
    "check_interval_seconds": 300,
    "snmp_enabled": true,
    "snmp_version": "2c",
    "snmp_port": 161,
    "inserted_at": "2026-01-15T19:44:25Z"
  }
  """
  def show(conn, %{"id" => id}) do
    organization_id = conn.assigns.current_organization_id

    device = Devices.get_device!(id)

    if device.organization_id == organization_id do
      json(conn, format_device_details(device))
    else
      conn
      |> put_status(:forbidden)
      |> json(%{error: "Access denied to this device"})
    end
  rescue
    Ecto.NoResultsError ->
      conn
      |> put_status(:not_found)
      |> json(%{error: "Device not found"})
  end

  @doc """
  PATCH /api/v1/devices/:id

  Updates a device.

  Request body:
  {
    "device": {
      "name": "Updated Name",
      "ip_address": "192.168.1.2"
    }
  }

  Response:
  {
    "id": "uuid",
    "name": "Updated Name",
    "ip_address": "192.168.1.2",
    ...
  }
  """
  def update(conn, %{"id" => id, "device" => device_params}) do
    organization_id = conn.assigns.current_organization_id

    device = Devices.get_device!(id)

    if device.organization_id == organization_id do
      case Devices.update_device(device, device_params) do
        {:ok, updated_device} ->
          json(conn, format_device_details(updated_device))

        {:error, %Ecto.Changeset{} = changeset} ->
          conn
          |> put_status(:unprocessable_entity)
          |> json(%{errors: translate_errors(changeset)})
      end
    else
      conn
      |> put_status(:forbidden)
      |> json(%{error: "Access denied to this device"})
    end
  rescue
    Ecto.NoResultsError ->
      conn
      |> put_status(:not_found)
      |> json(%{error: "Device not found"})
  end

  def update(conn, _params) do
    conn
    |> put_status(:bad_request)
    |> json(%{error: "Missing 'device' parameter"})
  end

  @doc """
  DELETE /api/v1/devices/:id

  Deletes a device.

  Response:
  {
    "success": true
  }
  """
  def delete(conn, %{"id" => id}) do
    organization_id = conn.assigns.current_organization_id

    device = Devices.get_device!(id)

    if device.organization_id == organization_id do
      case Devices.delete_device(device) do
        {:ok, _device} ->
          json(conn, %{success: true})

        {:error, %Ecto.Changeset{} = changeset} ->
          conn
          |> put_status(:unprocessable_entity)
          |> json(%{errors: translate_errors(changeset)})
      end
    else
      conn
      |> put_status(:forbidden)
      |> json(%{error: "Access denied to this device"})
    end
  rescue
    Ecto.NoResultsError ->
      conn
      |> put_status(:not_found)
      |> json(%{error: "Device not found"})
  end

  # Private helpers

  defp default_organization_id(device_params, organization_id) do
    case Map.get(device_params, "organization_id") do
      nil -> Map.put(device_params, "organization_id", organization_id)
      "" -> Map.put(device_params, "organization_id", organization_id)
      _provided_id -> device_params
    end
  end

  defp maybe_strip_site_id_if_sites_disabled(device_params, organization_id) do
    organization = Organizations.get_organization!(organization_id)

    # If sites are disabled and a site_id was provided, remove it
    # This ensures devices are assigned directly to the organization
    if !organization.use_sites && device_params["site_id"] do
      Map.delete(device_params, "site_id")
    else
      device_params
    end
  end

  defp create_and_discover_device(conn, device_params, current_user) do
    opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: []

    case Devices.create_device(device_params, opts) do
      {:ok, device} ->
        maybe_enqueue_discovery(device)

        conn
        |> put_status(:created)
        |> json(format_device(device))

      {:error, %Ecto.Changeset{} = changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> json(%{errors: translate_errors(changeset)})
    end
  end

  defp maybe_enqueue_discovery(device) do
    if device.snmp_enabled do
      DiscoveryWorker.enqueue(device.id)
    end
  end

  defp verify_site_access(nil, _organization_id), do: :ok

  defp verify_site_access(site_id, organization_id) do
    site = Towerops.Sites.get_site!(site_id)

    if site.organization_id == organization_id do
      :ok
    else
      {:error, "Access denied to this site"}
    end
  rescue
    Ecto.NoResultsError ->
      {:error, "Site not found"}
  end

  defp format_device(device) do
    %{
      id: device.id,
      name: device.name,
      ip_address: device.ip_address,
      site_id: device.site_id,
      organization_id: device.organization_id,
      monitoring_enabled: device.monitoring_enabled,
      snmp_enabled: device.snmp_enabled,
      snmp_version: device.snmp_version,
      snmp_port: device.snmp_port,
      snmpv3_security_level: device.snmpv3_security_level,
      snmpv3_username: device.snmpv3_username,
      snmpv3_auth_protocol: device.snmpv3_auth_protocol,
      snmpv3_auth_password: device.snmpv3_auth_password,
      snmpv3_priv_protocol: device.snmpv3_priv_protocol,
      snmpv3_priv_password: device.snmpv3_priv_password,
      inserted_at: device.inserted_at
    }
  end

  defp format_device_details(device) do
    %{
      id: device.id,
      name: device.name,
      ip_address: device.ip_address,
      site_id: device.site_id,
      organization_id: device.organization_id,
      description: device.description,
      monitoring_enabled: device.monitoring_enabled,
      check_interval_seconds: device.check_interval_seconds,
      snmp_enabled: device.snmp_enabled,
      snmp_version: device.snmp_version,
      snmp_port: device.snmp_port,
      snmpv3_security_level: device.snmpv3_security_level,
      snmpv3_username: device.snmpv3_username,
      snmpv3_auth_protocol: device.snmpv3_auth_protocol,
      snmpv3_auth_password: device.snmpv3_auth_password,
      snmpv3_priv_protocol: device.snmpv3_priv_protocol,
      snmpv3_priv_password: device.snmpv3_priv_password,
      inserted_at: device.inserted_at
    }
  end

  defp translate_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
      Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
        safe_translate_key(key, opts)
      end)
    end)
  end

  # Safely translate error message keys to prevent atom exhaustion
  defp safe_translate_key(key, opts) do
    # Validate key length and format before converting to atom to prevent abuse
    if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
      opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
    else
      key
    end
  end
end
