defmodule Towerops.Devices.Device do
  @moduledoc """
  Device schema for network devices being monitored.

  Devices belong to a site and organization, and can have:
  - SNMP configuration (community strings, polling settings)
  - Associated SNMP device record (interfaces, sensors)
  - Agent assignments for remote polling
  - Monitoring checks and alerts
  """
  use Ecto.Schema

  import Ecto.Changeset

  alias Ecto.Association.NotLoaded
  alias Towerops.Agents.AgentAssignment
  alias Towerops.Encrypted.Binary
  alias Towerops.Organizations.Organization
  alias Towerops.Sites.Site
  alias Towerops.Snmp.Device, as: SnmpDevice
  alias Towerops.Topology.DeviceLink

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "devices" do
    field :name, :string
    field :ip_address, :string
    field :description, :string
    field :display_order, :integer
    field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown
    field :last_checked_at, :utc_datetime
    field :last_status_change_at, :utc_datetime
    field :monitoring_enabled, :boolean, default: true
    field :check_interval_seconds, :integer, default: 300

    # SNMP fields
    field :snmp_enabled, :boolean, default: true
    field :snmp_version, :string, default: "2c"
    field :snmp_community, :string
    field :snmp_community_source, :string, default: "organization"
    field :snmp_port, :integer, default: 161
    field :snmp_transport, :string, default: "udp"
    field :snmp_transport_source, :string, default: "organization"
    field :last_discovery_at, :utc_datetime
    field :last_snmp_poll_at, :utc_datetime

    # SNMPv3 credentials (device-level overrides, all nullable)
    field :snmpv3_security_level, :string
    field :snmpv3_username, :string
    field :snmpv3_auth_protocol, :string
    field :snmpv3_auth_password, Binary
    field :snmpv3_priv_protocol, :string
    field :snmpv3_priv_password, Binary
    field :snmpv3_credential_source, :string, default: "site"

    # MikroTik API credentials (device-level overrides)
    field :mikrotik_username, :string
    field :mikrotik_password, Binary
    field :mikrotik_port, :integer
    field :mikrotik_ssh_port, :integer
    field :mikrotik_use_ssl, :boolean
    field :mikrotik_enabled, :boolean
    field :mikrotik_credential_source, :string, default: "site"

    # Topology role (auto-inferred or manually set)
    field :device_role, :string
    field :device_role_source, :string, default: "inferred"

    belongs_to :site, Site
    belongs_to :organization, Organization

    has_one :snmp_device, SnmpDevice
    has_many :agent_assignments, AgentAssignment
    has_many :outbound_links, DeviceLink, foreign_key: :source_device_id
    has_many :inbound_links, DeviceLink, foreign_key: :target_device_id

    timestamps(type: :utc_datetime)
  end

  @type t :: %__MODULE__{
          id: Ecto.UUID.t(),
          name: String.t() | nil,
          ip_address: String.t(),
          description: String.t() | nil,
          display_order: integer() | nil,
          status: :up | :down | :unknown,
          last_checked_at: DateTime.t() | nil,
          last_status_change_at: DateTime.t() | nil,
          monitoring_enabled: boolean(),
          check_interval_seconds: integer(),
          snmp_enabled: boolean(),
          snmp_version: String.t(),
          snmp_community: String.t() | nil,
          snmp_community_source: String.t(),
          snmp_port: integer(),
          snmp_transport: String.t(),
          snmp_transport_source: String.t(),
          last_discovery_at: DateTime.t() | nil,
          last_snmp_poll_at: DateTime.t() | nil,
          snmpv3_security_level: String.t() | nil,
          snmpv3_username: String.t() | nil,
          snmpv3_auth_protocol: String.t() | nil,
          snmpv3_auth_password: String.t() | nil,
          snmpv3_priv_protocol: String.t() | nil,
          snmpv3_priv_password: String.t() | nil,
          snmpv3_credential_source: String.t(),
          mikrotik_username: String.t() | nil,
          mikrotik_password: String.t() | nil,
          mikrotik_port: integer() | nil,
          mikrotik_ssh_port: integer() | nil,
          mikrotik_use_ssl: boolean() | nil,
          mikrotik_enabled: boolean() | nil,
          mikrotik_credential_source: String.t() | nil,
          device_role: String.t() | nil,
          device_role_source: String.t(),
          site_id: Ecto.UUID.t() | nil,
          site: NotLoaded.t() | Site.t() | nil,
          organization_id: Ecto.UUID.t(),
          organization: NotLoaded.t() | Organization.t() | nil,
          snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
          agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
          outbound_links: NotLoaded.t() | [DeviceLink.t()],
          inbound_links: NotLoaded.t() | [DeviceLink.t()],
          inserted_at: DateTime.t(),
          updated_at: DateTime.t()
        }

  @doc false
  def changeset(device, attrs) do
    device
    |> cast(attrs, [
      :name,
      :ip_address,
      :description,
      :display_order,
      :site_id,
      :organization_id,
      :monitoring_enabled,
      :check_interval_seconds,
      :snmp_enabled,
      :snmp_version,
      :snmp_community,
      :snmp_community_source,
      :snmp_port,
      :snmp_transport,
      :snmp_transport_source,
      :status,
      :last_checked_at,
      :last_status_change_at,
      :last_discovery_at,
      :last_snmp_poll_at,
      :snmpv3_security_level,
      :snmpv3_username,
      :snmpv3_auth_protocol,
      :snmpv3_auth_password,
      :snmpv3_priv_protocol,
      :snmpv3_priv_password,
      :snmpv3_credential_source,
      :mikrotik_username,
      :mikrotik_password,
      :mikrotik_port,
      :mikrotik_ssh_port,
      :mikrotik_use_ssl,
      :mikrotik_enabled,
      :mikrotik_credential_source,
      :device_role,
      :device_role_source
    ])
    |> validate_required([:ip_address, :organization_id])
    |> validate_name()
    |> validate_length(:name, min: 2, max: 200)
    |> validate_length(:description, max: 1000)
    |> validate_ip_address()
    |> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
    |> validate_snmp()
    |> validate_snmpv3_fields()
    |> validate_mikrotik()
    |> validate_site_belongs_to_organization()
    |> update_community_source()
    |> update_transport_source()
    |> update_snmpv3_credential_source()
    |> update_mikrotik_credential_source()
    |> foreign_key_constraint(:site_id)
    |> foreign_key_constraint(:organization_id)
  end

  defp validate_name(changeset) do
    snmp_enabled = get_field(changeset, :snmp_enabled, false)
    name = get_field(changeset, :name)

    # Name is required only when SNMP is not enabled (ICMP only mode)
    # When SNMP is enabled, name can be empty and will be populated from sysName during discovery
    if !snmp_enabled and (is_nil(name) or name == "") do
      add_error(changeset, :name, "can't be blank (required when SNMP is disabled)")
    else
      changeset
    end
  end

  defp validate_ip_address(changeset) do
    case get_change(changeset, :ip_address) do
      nil ->
        changeset

      ip_string ->
        case parse_ip(ip_string) do
          {:ok, _} ->
            changeset

          {:error, _} ->
            add_error(changeset, :ip_address, "must be a valid IPv4 or IPv6 address")
        end
    end
  end

  defp parse_ip(ip_string) do
    ip_string
    |> String.to_charlist()
    |> :inet.parse_address()
  end

  defp validate_site_belongs_to_organization(changeset) do
    site_id = get_field(changeset, :site_id)
    org_id = get_field(changeset, :organization_id)

    # If no site_id or org_id, no validation needed
    if !site_id || !org_id do
      changeset
    else
      # Verify that the site belongs to the same organization
      case Towerops.Repo.get(Site, site_id) do
        nil -> add_error(changeset, :site_id, "does not exist")
        %{organization_id: ^org_id} -> changeset
        _site -> add_error(changeset, :site_id, "must belong to the same organization")
      end
    end
  end

  defp validate_snmp(changeset) do
    snmp_enabled = get_field(changeset, :snmp_enabled)

    if snmp_enabled do
      changeset
      |> validate_required([:snmp_version], message: "required when SNMP is enabled")
      |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
      |> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
      |> validate_inclusion(:snmp_transport, ["udp", "tcp"], message: "must be udp or tcp")
    else
      changeset
    end
  end

  defp update_community_source(changeset) do
    # If user explicitly set a community string, mark source as "device"
    # If it's blank/nil, mark source as "site" (will inherit from site or org)
    case get_change(changeset, :snmp_community) do
      nil ->
        # No change to community, keep existing source
        changeset

      "" ->
        # Explicitly cleared, set to inherit from site
        put_change(changeset, :snmp_community_source, "site")

      _value ->
        # Explicitly set a value, mark as device-specific
        put_change(changeset, :snmp_community_source, "device")
    end
  end

  defp update_transport_source(changeset) do
    # If user explicitly set a transport, mark source as "device"
    # If it's blank/nil, mark source as "organization" (will inherit from site or org)
    case get_change(changeset, :snmp_transport) do
      nil ->
        # No change to transport, keep existing source
        changeset

      "" ->
        # Explicitly cleared, set to inherit from organization
        put_change(changeset, :snmp_transport_source, "organization")

      _value ->
        # Explicitly set a value, mark as device-specific
        put_change(changeset, :snmp_transport_source, "device")
    end
  end

  defp validate_mikrotik(changeset) do
    mikrotik_enabled = get_field(changeset, :mikrotik_enabled)

    if mikrotik_enabled do
      changeset
      |> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
      |> validate_mikrotik_ssl_for_cloud()
    else
      changeset
    end
  end

  defp validate_mikrotik_ssl_for_cloud(changeset) do
    # If explicitly setting use_ssl to false at device level, ensure not using cloud poller
    use_ssl = get_field(changeset, :mikrotik_use_ssl)

    # Only validate if explicitly disabling SSL (nil means inherit)
    if use_ssl == false do
      # Note: We can't check effective agent here during changeset validation
      # as it requires database queries. This validation happens at the context level.
      # Here we just ensure the field is set if provided.
      changeset
    else
      changeset
    end
  end

  defp validate_snmpv3_fields(changeset) do
    snmp_version = get_field(changeset, :snmp_version)

    if snmp_version == "3" do
      changeset
      |> validate_required([:snmpv3_username])
      |> derive_snmpv3_security_level()
      |> validate_snmpv3_auth_required()
      |> validate_snmpv3_priv_required()
      |> validate_password_length(:snmpv3_auth_password, 8)
      |> validate_password_length(:snmpv3_priv_password, 8)
    else
      changeset
    end
  end

  defp derive_snmpv3_security_level(changeset) do
    # Automatically derive security level based on what fields are present
    auth_protocol = get_field(changeset, :snmpv3_auth_protocol)
    priv_protocol = get_field(changeset, :snmpv3_priv_protocol)

    security_level =
      cond do
        auth_protocol in [nil, ""] -> "noAuthNoPriv"
        priv_protocol not in [nil, ""] -> "authPriv"
        true -> "authNoPriv"
      end

    put_change(changeset, :snmpv3_security_level, security_level)
  end

  defp validate_snmpv3_auth_required(changeset) do
    auth_protocol = get_field(changeset, :snmpv3_auth_protocol)

    if auth_protocol in [nil, ""] do
      changeset
    else
      changeset
      |> validate_required([:snmpv3_auth_password],
        message: "required when auth protocol is selected"
      )
      |> validate_inclusion(:snmpv3_auth_protocol, [
        "MD5",
        "SHA",
        "SHA-224",
        "SHA-256",
        "SHA-384",
        "SHA-512"
      ])
    end
  end

  defp validate_snmpv3_priv_required(changeset) do
    priv_protocol = get_field(changeset, :snmpv3_priv_protocol)

    if priv_protocol in [nil, ""] do
      changeset
    else
      changeset
      |> validate_required([:snmpv3_priv_password],
        message: "required when privacy protocol is selected"
      )
      |> validate_inclusion(:snmpv3_priv_protocol, ["DES", "AES", "AES-192", "AES-256", "AES-256-C"])
    end
  end

  defp validate_password_length(changeset, field, min_length) do
    case get_change(changeset, field) do
      nil ->
        changeset

      password when byte_size(password) < min_length ->
        add_error(changeset, field, "must be at least #{min_length} characters")

      _ ->
        changeset
    end
  end

  defp update_snmpv3_credential_source(changeset) do
    # If user explicitly set a username, mark source as "device"
    # If it's blank/nil/cleared, mark source as "site" (will inherit from site or org)
    username_change = get_change(changeset, :snmpv3_username)
    current_username = get_field(changeset, :snmpv3_username)

    cond do
      # Explicitly set to empty string - clear and inherit
      username_change == "" ->
        changeset
        |> put_change(:snmpv3_username, nil)
        |> put_change(:snmpv3_credential_source, "site")

      # Explicitly set to a value - mark as device-specific
      username_change != nil && username_change != "" ->
        put_change(changeset, :snmpv3_credential_source, "device")

      # No change to username, but check if we should reset source
      username_change == nil && (current_username == nil || current_username == "") &&
          get_field(changeset, :snmpv3_credential_source) == "device" ->
        put_change(changeset, :snmpv3_credential_source, "site")

      # No change, keep existing
      true ->
        changeset
    end
  end

  defp update_mikrotik_credential_source(changeset) do
    # If user explicitly set a username, mark source as "device"
    # If it's blank/nil/cleared, mark source as "site" (will inherit from site or org)
    username_change = get_change(changeset, :mikrotik_username)
    current_username = get_field(changeset, :mikrotik_username)

    cond do
      # Explicitly set to empty string - clear and inherit
      username_change == "" ->
        changeset
        |> put_change(:mikrotik_username, nil)
        |> put_change(:mikrotik_credential_source, "site")

      # Explicitly set to a value - mark as device-specific
      username_change != nil && username_change != "" ->
        put_change(changeset, :mikrotik_credential_source, "device")

      # No change to username, but check if we should reset source
      # (This handles cases where other MikroTik fields are cleared)
      username_change == nil && (current_username == nil || current_username == "") &&
          get_field(changeset, :mikrotik_credential_source) == "device" ->
        put_change(changeset, :mikrotik_credential_source, "site")

      # No change, keep existing
      true ->
        changeset
    end
  end
end
