defmodule Aprsme.DeviceIdentificationTest do
  use Aprsme.DataCase, async: true

  alias Aprsme.DeviceIdentification
  alias Aprsme.Devices
  alias Aprsme.Repo

  doctest DeviceIdentification

  describe "identify_device/1" do
    test "identifies Original MIC-E devices" do
      assert DeviceIdentification.identify_device(" " <> <<0, 0>>) == "Original MIC-E"
    end

    test "identifies Kenwood devices" do
      assert DeviceIdentification.identify_device(">" <> <<0>> <> "^") == "Kenwood TH-D74"
      assert DeviceIdentification.identify_device(">" <> <<0, 0>>) == "Kenwood TH-D74A"
      assert DeviceIdentification.identify_device("]" <> <<0>> <> "=") == "Kenwood DM-710"
      assert DeviceIdentification.identify_device("]" <> <<0, 0>>) == "Kenwood DM-700"
    end

    test "identifies Yaesu devices" do
      assert DeviceIdentification.identify_device("`_" <> " ") == "Yaesu VX-8"
      assert DeviceIdentification.identify_device("`_" <> "\"") == "Yaesu FTM-350"
      assert DeviceIdentification.identify_device("`_" <> "#") == "Yaesu VX-8G"
      assert DeviceIdentification.identify_device("`_" <> "$") == "Yaesu FT1D"
      assert DeviceIdentification.identify_device("`_" <> "%") == "Yaesu FTM-400DR"
      assert DeviceIdentification.identify_device("`_" <> ")") == "Yaesu FTM-100D"
      assert DeviceIdentification.identify_device("`_" <> "(") == "Yaesu FT2D"
    end

    test "identifies other devices" do
      assert DeviceIdentification.identify_device("` X") == "AP510"
      assert DeviceIdentification.identify_device("`" <> <<0, 0>>) == "Mic-Emsg"
      assert DeviceIdentification.identify_device("'|3") == "Byonics TinyTrack3"
      assert DeviceIdentification.identify_device("'|4") == "Byonics TinyTrack4"

      assert DeviceIdentification.identify_device("':4") ==
               "SCS GmbH & Co. P4dragon DR-7400 modems"

      assert DeviceIdentification.identify_device("':8") ==
               "SCS GmbH & Co. P4dragon DR-7800 modems"

      assert DeviceIdentification.identify_device("'" <> <<0, 0>>) == "McTrackr"
      assert DeviceIdentification.identify_device(<<0>> <> "\"" <> <<0>>) == "Hamhud"
      assert DeviceIdentification.identify_device(<<0>> <> "/" <> <<0>>) == "Argent"
      assert DeviceIdentification.identify_device(<<0>> <> "^" <> <<0>>) == "HinzTec anyfrog"

      assert DeviceIdentification.identify_device(<<0>> <> "*" <> <<0>>) ==
               "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"

      assert DeviceIdentification.identify_device(<<0>> <> "~" <> <<0>>) == "Other"
    end

    test "returns Unknown for unidentified devices" do
      assert DeviceIdentification.identify_device(<<0, 0, 0>>) == "Unknown"
    end
  end

  describe "known_manufacturers/0" do
    test "returns list of known manufacturers" do
      manufacturers = DeviceIdentification.known_manufacturers()
      assert "Kenwood" in manufacturers
      assert "Yaesu" in manufacturers
      assert "Byonics" in manufacturers
    end
  end

  describe "known_models/1" do
    test "returns list of known models for Kenwood" do
      models = DeviceIdentification.known_models("Kenwood")
      assert models != []
      assert "TH-D74" in models
      assert "TH-D74A" in models
      assert "DM-710" in models
      assert "DM-700" in models
    end

    test "returns list of known models for Yaesu" do
      models = DeviceIdentification.known_models("Yaesu")
      assert models != []
      assert "VX-8" in models
      assert "FTM-350" in models
      assert "VX-8G" in models
      assert "FT1D" in models
      assert "FTM-400DR" in models
      assert "FTM-100D" in models
      assert "FT2D" in models
    end

    test "returns empty list for unknown manufacturer" do
      assert DeviceIdentification.known_models("Unknown") == []
    end

    test "returns list of known models for Byonics" do
      models = DeviceIdentification.known_models("Byonics")
      assert "TinyTrack3" in models
      assert "TinyTrack4" in models
    end

    test "returns list of known models for SCS GmbH & Co." do
      models = DeviceIdentification.known_models("SCS GmbH & Co.")
      assert "P4dragon DR-7400 modems" in models
      assert "P4dragon DR-7800 modems" in models
    end
  end

  describe "upsert_devices/1" do
    test "replaces the devices table with data from the JSON" do
      # Seed some dummy state to be wiped.
      Repo.delete_all(Devices)

      Repo.insert!(%Devices{
        identifier: "WILLBEWIPED",
        vendor: "Old",
        model: "Old"
      })

      json = %{
        "tocalls" => %{
          "APXYZ" => %{"vendor" => "NewVendor", "model" => "NewModel", "features" => ["a", "b"]}
        },
        "mice" => %{
          "]=" => %{"vendor" => "KenwoodMice", "model" => "TH-D7", "features" => "single"}
        }
      }

      assert :ok == DeviceIdentification.upsert_devices(json)

      # Previous row is gone, new rows inserted with correct attrs.
      assert Repo.get_by(Devices, identifier: "WILLBEWIPED") == nil

      assert %Devices{vendor: "NewVendor", model: "NewModel", features: ["a", "b"]} =
               Repo.get_by(Devices, identifier: "APXYZ")

      # Single-string feature gets wrapped into a list by process_device_attrs.
      assert %Devices{features: ["single"]} = Repo.get_by(Devices, identifier: "]=")
    end

    test "handles empty JSON groups" do
      Repo.delete_all(Devices)
      assert :ok == DeviceIdentification.upsert_devices(%{})
      assert Repo.all(Devices) == []
    end
  end

  describe "maybe_refresh_devices/0" do
    test "returns :ok when the most recent device is fresh (< 1 week old)" do
      Repo.delete_all(Devices)
      now = NaiveDateTime.utc_now(:second)

      Repo.insert!(%Devices{
        identifier: "FRESHROW",
        vendor: "v",
        model: "m",
        inserted_at: now,
        updated_at: now
      })

      assert :ok == DeviceIdentification.maybe_refresh_devices()
    end

    test "attempts to refresh when there are no devices at all" do
      Repo.delete_all(Devices)
      # fetch_and_upsert_devices goes through the circuit breaker. The short
      # Req timeout configured in test.exs makes the HTTP attempt fail fast.
      result = DeviceIdentification.maybe_refresh_devices()

      if result != :ok do
        assert match?({:error, _}, result)
      end
    end
  end

  describe "lookup_device_by_identifier/1 wildcard and escape handling" do
    test "matches with a multi-character ? wildcard pattern" do
      Repo.delete_all(Devices)

      Repo.insert!(%Devices{
        identifier: "APS???",
        vendor: "TestVendor",
        model: "TestModel"
      })

      devices = Repo.all(Devices)
      Aprsme.Cache.put(:device_cache, :all_devices, devices)

      # 'APSK21' has 6 characters, matching 'APS???'.
      assert %Devices{identifier: "APS???"} = DeviceIdentification.lookup_device_by_identifier("APSK21")
      # 'APS' alone is too short.
      refute DeviceIdentification.lookup_device_by_identifier("APS")
      # Different prefix should not match.
      refute DeviceIdentification.lookup_device_by_identifier("ZZZK21")
    end

    test "patterns with regex metacharacters are escaped properly" do
      Repo.delete_all(Devices)

      Repo.insert!(%Devices{
        identifier: "A.B+C*",
        vendor: "Regex",
        model: "Edge"
      })

      devices = Repo.all(Devices)
      Aprsme.Cache.put(:device_cache, :all_devices, devices)

      # The dot/plus/star should be treated as literals (no ? in pattern
      # means exact match is used).
      assert %Devices{identifier: "A.B+C*"} =
               DeviceIdentification.lookup_device_by_identifier("A.B+C*")

      # A string that would "match" if dot/plus/star were regex metachars
      # must NOT match — they're literals.
      refute DeviceIdentification.lookup_device_by_identifier("AXBCC")
    end
  end

  describe "fetch_devices_from_url/1 with direct URL" do
    test "returns an error tuple on non-200 response" do
      # httpbin.org-style path that returns non-200 is not reliable;
      # just pass an invalid URL to hit the error branch.
      result = DeviceIdentification.fetch_devices_from_url("http://invalid.local.test.invalid:1/404")
      assert match?({:error, _}, result)
    end

    test "returns http_error tuple on 404 response via Req plug" do
      original_opts = Application.get_env(:aprsme, :device_id_req_opts, [])

      try do
        # Pass an inline plug function directly (not wrapped in Req.Test).
        Application.put_env(:aprsme, :device_id_req_opts,
          plug: fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end
        )

        assert {:error, {:http_error, 404}} =
                 DeviceIdentification.fetch_devices_from_url("https://example.test/404")
      after
        Application.put_env(:aprsme, :device_id_req_opts, original_opts)
      end
    end

    test "returns :ok and upserts devices on 200 response via Req plug" do
      original_opts = Application.get_env(:aprsme, :device_id_req_opts, [])

      Repo.delete_all(Devices)

      try do
        Application.put_env(:aprsme, :device_id_req_opts,
          plug: fn conn ->
            body =
              Jason.encode!(%{
                "tocalls" => %{
                  "APMOCK" => %{
                    "vendor" => "MockVendor",
                    "model" => "MockModel"
                  }
                }
              })

            conn
            |> Plug.Conn.put_resp_content_type("application/json")
            |> Plug.Conn.send_resp(200, body)
          end
        )

        assert :ok = DeviceIdentification.fetch_devices_from_url("https://example.test/devices.json")
        assert %Devices{vendor: "MockVendor", model: "MockModel"} = Repo.get_by(Devices, identifier: "APMOCK")
      after
        Application.put_env(:aprsme, :device_id_req_opts, original_opts)
      end
    end
  end

  describe "fetch_and_upsert_devices circuit-breaker path" do
    test "returns an error tuple when circuit breaker rejects or HTTP fails" do
      # This just exercises the fetch_and_upsert_devices path, which runs via
      # Aprsme.CircuitBreaker.call/3. Without an HTTP stub, this will get an
      # error back from either the breaker or the underlying Req call.
      result = DeviceIdentification.fetch_and_upsert_devices()

      if result != :ok do
        assert match?({:error, _}, result)
      end
    end

    test "fetch_devices_from_url/0 default URL exercises the default-arg head" do
      original_opts = Application.get_env(:aprsme, :device_id_req_opts, [])

      try do
        # Stub out the Req call so the default URL doesn't actually hit the network.
        Application.put_env(:aprsme, :device_id_req_opts,
          plug: fn conn -> Plug.Conn.send_resp(conn, 200, ~s({"tocalls": {}})) end
        )

        # Calling without args binds url to the module-level default constant.
        result = DeviceIdentification.fetch_devices_from_url()

        if result != :ok do
          assert match?({:error, _}, result)
        end
      after
        Application.put_env(:aprsme, :device_id_req_opts, original_opts)
      end
    end
  end

  describe "lookup_device_from_db fallback when cache is unavailable" do
    test "lookup_device_by_identifier falls back to direct DB when DeviceCache is unavailable" do
      Repo.delete_all(Devices)

      Repo.insert!(%Devices{
        identifier: "DBEXACT",
        vendor: "Test",
        model: "Exact"
      })

      # Clear the cache so the code path exercises the DB lookup.
      Aprsme.Cache.put(:device_cache, :all_devices, nil)

      # Cache miss → DeviceCache returns nil and also triggers async refresh.
      # Direct lookup_device_by_identifier goes via DeviceCache here.
      result = DeviceIdentification.lookup_device_by_identifier("DBEXACT")

      # Either cache miss → nil, or cache refresh filled and matched.
      if result do
        assert is_struct(result, Devices)
        assert result.identifier == "DBEXACT"
      else
        assert is_nil(result)
      end
    end
  end

  describe "upsert_devices/1 with all three groups" do
    test "processes devices from mice and micelegacy groups" do
      Repo.delete_all(Devices)

      json = %{
        "tocalls" => %{},
        "mice" => %{
          "]=" => %{"vendor" => "Mice", "model" => "MA"}
        },
        "micelegacy" => %{
          "`_#" => %{"vendor" => "Legacy", "model" => "MB"}
        }
      }

      assert :ok = DeviceIdentification.upsert_devices(json)
      assert %Devices{vendor: "Mice"} = Repo.get_by(Devices, identifier: "]=")
      assert %Devices{vendor: "Legacy"} = Repo.get_by(Devices, identifier: "`_#")
    end

    test "filters out non-whitelisted JSON keys" do
      Repo.delete_all(Devices)

      # 'garbage' and 'unused_field' must be stripped by process_device_attrs.
      json = %{
        "tocalls" => %{
          "APRS" => %{
            "vendor" => "V",
            "model" => "M",
            "garbage" => "should-be-dropped",
            "unused_field" => 42
          }
        }
      }

      assert :ok = DeviceIdentification.upsert_devices(json)
      assert %Devices{vendor: "V", model: "M"} = Repo.get_by(Devices, identifier: "APRS")
    end

    test "handles nil features value" do
      Repo.delete_all(Devices)

      json = %{
        "tocalls" => %{
          "APNIL" => %{"vendor" => "V", "model" => "M", "features" => nil}
        }
      }

      assert :ok = DeviceIdentification.upsert_devices(json)
      dev = Repo.get_by(Devices, identifier: "APNIL")
      assert is_nil(dev.features)
    end
  end

  describe "maybe_refresh_devices/0 with stale device" do
    test "triggers refresh when latest device is older than one week" do
      Repo.delete_all(Devices)
      # 10 days ago.
      old_time =
        DateTime.utc_now()
        |> DateTime.add(-10 * 24 * 3600, :second)
        |> DateTime.to_naive()
        |> NaiveDateTime.truncate(:second)

      Repo.insert!(%Devices{
        identifier: "STALEROW",
        vendor: "v",
        model: "m",
        inserted_at: old_time,
        updated_at: old_time
      })

      # Will attempt a refresh via the CircuitBreaker. Without a real HTTP
      # stub, it may return an error tuple — either way the stale-branch was
      # exercised.
      result = DeviceIdentification.maybe_refresh_devices()

      if result != :ok do
        assert match?({:error, _}, result)
      end
    end
  end

  describe "lookup_device_by_identifier/1" do
    test "matches APSK21 to APS??? pattern" do
      # Seed the devices table from the JSON
      Aprsme.DevicesSeeder.seed_from_json()

      # Force cache refresh in test environment
      if Code.ensure_loaded?(Aprsme.DeviceCache) do
        # Manually refresh cache with seeded data
        devices = Repo.all(Devices)
        Aprsme.Cache.put(:device_cache, :all_devices, devices)
      end

      # Should match
      found = DeviceIdentification.lookup_device_by_identifier("APSK21")
      assert %Devices{} = found
      assert found.identifier == "APS???"
      assert found.model
      assert found.vendor
    end

    test "handles non-wildcard exact match" do
      # Insert a device with an exact identifier (no ?).
      Repo.delete_all(Devices)

      Repo.insert!(%Devices{
        identifier: "EXACTMATCH",
        vendor: "Vendor",
        model: "Model"
      })

      # Refresh the cache so lookups see the new record.
      devices = Repo.all(Devices)
      Aprsme.Cache.put(:device_cache, :all_devices, devices)

      found = DeviceIdentification.lookup_device_by_identifier("EXACTMATCH")
      assert %Devices{} = found
      assert found.identifier == "EXACTMATCH"

      refute DeviceIdentification.lookup_device_by_identifier("NOTEXACTMATCH")
    end

    test "returns nil for nil input" do
      assert DeviceIdentification.lookup_device_by_identifier(nil) == nil
    end

    test "matches Mic-E device identifier from raw packet" do
      # Seed the devices table from the JSON
      Aprsme.DevicesSeeder.seed_from_json()

      # Force cache refresh in test environment
      if Code.ensure_loaded?(Aprsme.DeviceCache) do
        # Manually refresh cache with seeded data
        devices = Repo.all(Devices)
        Aprsme.Cache.put(:device_cache, :all_devices, devices)
      end

      # The device identifier extracted from the raw packet is "]="
      found = DeviceIdentification.lookup_device_by_identifier("]=")
      assert %Devices{} = found
      assert found.identifier == "]="
      assert found.model
      assert found.vendor
    end
  end
end
