defmodule Microwaveprop.Propagation.ModelTest do
  use ExUnit.Case, async: true

  alias Microwaveprop.Propagation.Model

  describe "build/0" do
    test "returns an Axon model" do
      model = Model.build()
      assert %Axon{} = model
    end
  end

  describe "init/0" do
    test "returns initialized model state with parameters" do
      params = Model.init()
      assert %Axon.ModelState{} = params
      assert Map.has_key?(params.data, "hidden_1")
      assert Map.has_key?(params.data, "hidden_2")
      assert Map.has_key?(params.data, "hidden_3")
      assert Map.has_key?(params.data, "output")
    end
  end

  describe "save/2 and load/1" do
    @tag :tmp_dir
    test "round-trips parameters to disk", %{tmp_dir: tmp_dir} do
      path = Path.join(tmp_dir, "test_model.nx")
      params = Model.init()

      assert :ok = Model.save(params, path)
      assert File.exists?(path)

      assert {:ok, loaded} = Model.load(path)

      # Predict with both and compare
      input = Nx.broadcast(0.5, {1, 20})
      original = Model.predict(params, input)
      reloaded = Model.predict(loaded, input)

      assert Nx.to_flat_list(original) == Nx.to_flat_list(reloaded)
    end

    test "load returns :error for missing file" do
      assert :error = Model.load("/tmp/nonexistent_model.nx")
    end
  end

  describe "load_or_init/1" do
    @tag :tmp_dir
    test "loads from file when it exists", %{tmp_dir: tmp_dir} do
      path = Path.join(tmp_dir, "test_model.nx")
      params = Model.init()
      Model.save(params, path)

      loaded = Model.load_or_init(path)
      input = Nx.broadcast(0.5, {1, 20})
      assert Nx.to_flat_list(Model.predict(params, input)) == Nx.to_flat_list(Model.predict(loaded, input))
    end

    test "initializes when file missing" do
      params = Model.load_or_init("/tmp/nonexistent_model.nx")
      assert %Axon.ModelState{} = params
    end
  end

  describe "predict/2" do
    test "produces output in [0, 1] range" do
      params = Model.init()
      # Single sample, 20 features
      input = Nx.broadcast(0.5, {1, 20})
      output = Model.predict(params, input)

      assert Nx.shape(output) == {1, 1}

      value = output |> Nx.to_flat_list() |> hd()
      assert value >= 0.0 and value <= 1.0
    end

    test "handles batch of multiple samples" do
      params = Model.init()
      input = Nx.broadcast(0.5, {4, 20})
      output = Model.predict(params, input)

      assert Nx.shape(output) == {4, 1}
    end
  end

  describe "encode_features/1" do
    test "returns 20-element list" do
      features =
        Model.encode_features(%{
          surface_temp_c: 25.0,
          surface_dewpoint_c: 15.0,
          surface_pressure_mb: 1015.0,
          min_refractivity_gradient: -80.0,
          hpbl_m: 600.0,
          pwat_mm: 25.0,
          utc_hour: 18,
          month: 7,
          freq_mhz: 10_000
        })

      assert length(features) == 20
      assert Enum.all?(features, &is_float/1)
    end

    test "uses defaults for missing values" do
      features = Model.encode_features(%{})
      assert length(features) == 20
      assert Enum.all?(features, &is_float/1)
    end

    test "cyclical encoding: hour 0 and hour 24 produce same sin/cos" do
      f0 = Model.encode_features(%{utc_hour: 0})
      f24 = Model.encode_features(%{utc_hour: 24})

      # sin/cos at indices 8, 9
      assert_in_delta Enum.at(f0, 8), Enum.at(f24, 8), 0.001
      assert_in_delta Enum.at(f0, 9), Enum.at(f24, 9), 0.001
    end

    test "log_freq_mhz is last feature" do
      features = Model.encode_features(%{freq_mhz: 10_000})
      assert_in_delta List.last(features), :math.log(10_000) / 13.0, 0.001
    end
  end

  describe "train/3" do
    test "trains on synthetic data and returns state with metrics" do
      # Generate small synthetic dataset: 64 samples, 13 features
      key = Nx.Random.key(42)
      {features, key} = Nx.Random.uniform(key, shape: {64, 20}, type: :f32)
      {targets, _key} = Nx.Random.uniform(key, shape: {64, 1}, type: :f32)

      {trained_state, metrics} = Model.train(features, targets, epochs: 2, batch_size: 32)

      assert %Axon.ModelState{} = trained_state
      assert is_float(metrics.final_loss)
      assert metrics.final_loss >= 0.0
    end
  end

  describe "evaluate/3" do
    test "returns rmse and r_squared" do
      params = Model.init()
      key = Nx.Random.key(42)
      {features, key} = Nx.Random.uniform(key, shape: {32, 20}, type: :f32)
      {targets, _key} = Nx.Random.uniform(key, shape: {32, 1}, type: :f32)

      metrics = Model.evaluate(params, features, targets)

      assert is_float(metrics.rmse)
      assert metrics.rmse >= 0.0
      assert is_float(metrics.r_squared)
    end
  end

  describe "predict_score/3" do
    test "returns integer between 0 and 100" do
      params = Model.init()
      predict_fn = Model.compile_predict()

      score =
        Model.predict_score(predict_fn, params, %{
          surface_temp_c: 25.0,
          surface_dewpoint_c: 15.0,
          surface_pressure_mb: 1015.0,
          min_refractivity_gradient: -80.0,
          hpbl_m: 600.0,
          pwat_mm: 25.0,
          utc_hour: 18,
          month: 7,
          freq_mhz: 10_000
        })

      assert is_integer(score)
      assert score >= 0
      assert score <= 100
    end

    test "returns different scores for different conditions" do
      params = Model.init()
      predict_fn = Model.compile_predict()

      score_hot =
        Model.predict_score(predict_fn, params, %{
          surface_temp_c: 35.0,
          surface_dewpoint_c: 25.0,
          surface_pressure_mb: 1010.0,
          min_refractivity_gradient: -300.0,
          hpbl_m: 200.0,
          pwat_mm: 40.0,
          utc_hour: 3,
          month: 7,
          freq_mhz: 10_000
        })

      score_cold =
        Model.predict_score(predict_fn, params, %{
          surface_temp_c: -10.0,
          surface_dewpoint_c: -20.0,
          surface_pressure_mb: 1030.0,
          min_refractivity_gradient: -40.0,
          hpbl_m: 1500.0,
          pwat_mm: 5.0,
          utc_hour: 14,
          month: 1,
          freq_mhz: 10_000
        })

      # With random init, scores likely differ (not guaranteed but very probable)
      assert is_integer(score_hot)
      assert is_integer(score_cold)
    end
  end

  describe "feature_names/0" do
    test "returns 20 feature names" do
      assert length(Model.feature_names()) == 20
    end
  end
end
