defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
  @moduledoc """
  Characterization tests for the catch-all admin task dispatcher.

  `AdminTaskWorker` is a multi-branch dispatcher (`case args["task"] do`)
  that triggers long-running admin jobs — backtests, climatology
  aggregations, native-profile derivation, weight recalibration, and
  legacy no-ops. Each branch has its own downstream surface area:

    * `backtest_all` / `backtest` — delegate to `Microwaveprop.Backtest`
      and write Markdown to `priv/backtest_reports/`. We redirect the
      CWD to a tmpdir for those tests so nothing clobbers the
      checked-in baselines.

    * `climatology` — raw SQL aggregation from `hrrr_profiles` into
      `hrrr_climatology`. We seed `hrrr_profiles` rows directly and
      assert on upsert results.

    * `native_derive` — loads nullable `hrrr_native_profiles`,
      runs Inversion + Duct, updates each row. We seed a minimal
      native profile.

    * `recalibrate` — delegates to `Recalibrator.fit/1`. With no
      contacts in the sandbox it returns the insufficient-data shape.

    * `scorer_diff` — documented no-op.

    * unknown task — catch-all returns `{:error, _}`.

  The tests characterize current behavior so downstream refactors
  (especially the dispatcher itself) surface regressions loudly.
  """
  use Microwaveprop.DataCase, async: false
  use Oban.Testing, repo: Microwaveprop.Repo

  alias Microwaveprop.Repo
  alias Microwaveprop.Weather.HrrrClimatology
  alias Microwaveprop.Weather.HrrrNativeProfile
  alias Microwaveprop.Weather.HrrrProfile
  alias Microwaveprop.Workers.AdminTaskWorker

  # Redirect `priv/backtest_reports/` writes to a per-test tmp dir by
  # flipping cwd for the duration of the test. The worker uses a
  # relative path (`"priv/backtest_reports/..."`) so chdir is enough —
  # no source change needed.
  defp with_tmp_cwd(fun) do
    original = File.cwd!()
    tmp = Path.join(System.tmp_dir!(), "admin_task_test_#{System.unique_integer([:positive])}")
    File.mkdir_p!(tmp)
    File.cd!(tmp)

    try do
      fun.(tmp)
    after
      File.cd!(original)
      File.rm_rf!(tmp)
    end
  end

  describe "perform/1 — task=backtest_all" do
    test "with no contacts, writes an empty consolidated markdown report" do
      with_tmp_cwd(fn tmp ->
        # sample_size is bounded so the feature scan is quick; with zero
        # contacts in the sandbox every feature reports count=0.
        assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all", "sample_size" => 10}})

        path = Path.join(tmp, "priv/backtest_reports/consolidated.md")
        assert File.exists?(path)
        body = File.read!(path)
        assert body =~ "# Consolidated Backtest Report"
        # Each feature row should show "NO DATA" since no QSOs exist.
        assert body =~ "NO DATA"
      end)
    end

    test "defaults sample_size when not provided" do
      with_tmp_cwd(fn tmp ->
        assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all"}})
        assert File.exists?(Path.join(tmp, "priv/backtest_reports/consolidated.md"))
      end)
    end
  end

  describe "perform/1 — task=backtest" do
    test "writes a per-feature markdown report for a known feature" do
      # `time_of_day` is a legitimate public function on
      # `Microwaveprop.Backtest.Features`. The guard uses
      # `Code.ensure_loaded?/1` before `function_exported?/3`, so the
      # module is forced-loaded and the guard does not spuriously reject
      # valid feature names on a cold VM.
      with_tmp_cwd(fn tmp ->
        args = %{"task" => "backtest", "feature" => "time_of_day", "sample_size" => 10}
        assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})

        path = Path.join(tmp, "priv/backtest_reports/time_of_day.md")
        assert File.exists?(path)
        body = File.read!(path)
        assert body =~ "# Backtest: Microwaveprop.Backtest.Features.time_of_day"
        assert body =~ "## Matched distribution"
      end)
    end

    test "returns {:error, _} and writes no report for an unknown feature" do
      # An unknown feature name must short-circuit: the worker returns
      # `{:error, "unknown feature: ..."}` before invoking
      # `Backtest.evaluate` and must NOT write a report file. Previously
      # the guard's `{:error, _}` tuple was the `if` block's return
      # value and got discarded, so execution fell through to write an
      # empty report.
      with_tmp_cwd(fn tmp ->
        args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10}

        assert {:error, "unknown feature: does_not_exist_feature_xyz"} =
                 AdminTaskWorker.perform(%Oban.Job{args: args})

        refute File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
      end)
    end
  end

  describe "perform/1 — task=climatology" do
    test "aggregates mean/stddev per (lat, lon, month, hour) when sample_count meets min_samples" do
      # Seed four HRRR profiles at the same grid point, same month+hour,
      # but different years. Surface temps: 10, 20, 30, 40 → mean 25.0,
      # stddev_samp = sqrt(sum((x-25)^2)/3) = sqrt(500/3) ≈ 12.9099.
      lat = 32.9
      lon = -97.0

      for {temp, year} <- [{10.0, 2023}, {20.0, 2024}, {30.0, 2025}, {40.0, 2026}] do
        %HrrrProfile{}
        |> HrrrProfile.changeset(%{
          lat: lat,
          lon: lon,
          valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
          surface_temp_c: temp,
          is_grid_point: true
        })
        |> Repo.insert!()
      end

      assert :ok =
               AdminTaskWorker.perform(%Oban.Job{
                 args: %{"task" => "climatology", "min_samples" => 3}
               })

      [row] = Repo.all(HrrrClimatology)
      assert row.lat == lat
      assert row.lon == lon
      assert row.month == 6
      assert row.hour == 18
      assert row.sample_count == 4
      assert_in_delta row.mean_surface_temp_c, 25.0, 1.0e-6
      assert_in_delta row.stddev_surface_temp_c, :math.sqrt(500.0 / 3.0), 1.0e-4
    end

    test "skips grid cells that don't meet min_samples" do
      %HrrrProfile{}
      |> HrrrProfile.changeset(%{
        lat: 32.9,
        lon: -97.0,
        valid_time: ~U[2025-06-15 18:00:00Z],
        surface_temp_c: 20.0,
        is_grid_point: true
      })
      |> Repo.insert!()

      assert :ok =
               AdminTaskWorker.perform(%Oban.Job{
                 args: %{"task" => "climatology", "min_samples" => 3}
               })

      # The (6, 18) combo shows up in the outer query because at least
      # one row exists, but the inner upsert's HAVING COUNT(*) >= 3
      # filters the single-row grid cell out.
      assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
    end

    test "ignores non-grid-point profiles" do
      # is_grid_point = false rows are filtered by the WHERE clause.
      # Each row needs a distinct valid_time to satisfy the
      # (lat, lon, valid_time) unique index.
      for year <- 2022..2026 do
        %HrrrProfile{}
        |> HrrrProfile.changeset(%{
          lat: 32.9,
          lon: -97.0,
          valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
          surface_temp_c: 25.0,
          is_grid_point: false
        })
        |> Repo.insert!()
      end

      assert :ok =
               AdminTaskWorker.perform(%Oban.Job{
                 args: %{"task" => "climatology", "min_samples" => 3}
               })

      assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
    end

    test "is a no-op when there are no matching profiles at all" do
      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "climatology"}})
      assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
    end
  end

  describe "perform/1 — task=native_derive" do
    test "derives fields for profiles with null bulk_richardson and level_count > 2" do
      # Minimal 3-level profile. Inversion module needs heights_m + temp_k
      # to be non-trivial; Duct.analyze just needs the full parallel arrays.
      profile =
        %HrrrNativeProfile{}
        |> HrrrNativeProfile.changeset(%{
          valid_time: ~U[2026-04-15 18:00:00Z],
          lat: 32.9,
          lon: -97.0,
          level_count: 3,
          heights_m: [10.0, 100.0, 500.0],
          temp_k: [290.0, 292.0, 285.0],
          spfh: [0.010, 0.008, 0.005],
          pressure_pa: [101_000.0, 99_500.0, 95_000.0],
          u_wind_ms: [2.0, 3.0, 5.0],
          v_wind_ms: [1.0, 1.5, 2.5],
          tke_m2s2: [0.1, 0.2, 0.3]
        })
        |> Repo.insert!()

      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})

      # Updated row: ducts and best_duct_band_ghz fields are written by
      # Duct.analyze (nil if no duct), and inversion fields are written
      # by Inversion.find_inversion_top (nil if no inversion). We just
      # assert the worker touched the row by reloading it.
      reloaded = Repo.get!(HrrrNativeProfile, profile.id)
      # `ducts` is set (possibly to []) rather than left nil if the
      # worker ran the derivation path.
      assert reloaded.ducts
    end

    test "skips profiles where bulk_richardson is already set" do
      pre_set =
        %HrrrNativeProfile{}
        |> HrrrNativeProfile.changeset(%{
          valid_time: ~U[2026-04-15 18:00:00Z],
          lat: 32.9,
          lon: -97.0,
          level_count: 3,
          heights_m: [10.0, 100.0, 500.0],
          temp_k: [290.0, 292.0, 285.0],
          spfh: [0.010, 0.008, 0.005],
          pressure_pa: [101_000.0, 99_500.0, 95_000.0],
          u_wind_ms: [2.0, 3.0, 5.0],
          v_wind_ms: [1.0, 1.5, 2.5],
          tke_m2s2: [0.1, 0.2, 0.3],
          bulk_richardson: 0.5
        })
        |> Repo.insert!()

      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})

      # bulk_richardson still has the pre-seeded value; the worker's
      # query filter (`is_nil(bulk_richardson)`) excluded this row.
      reloaded = Repo.get!(HrrrNativeProfile, pre_set.id)
      assert reloaded.bulk_richardson == 0.5
    end

    test "skips profiles with level_count <= 2" do
      profile =
        %HrrrNativeProfile{}
        |> HrrrNativeProfile.changeset(%{
          valid_time: ~U[2026-04-15 18:00:00Z],
          lat: 32.9,
          lon: -97.0,
          level_count: 2,
          heights_m: [10.0, 100.0],
          temp_k: [290.0, 285.0],
          spfh: [0.010, 0.008],
          pressure_pa: [101_000.0, 99_500.0],
          u_wind_ms: [2.0, 3.0],
          v_wind_ms: [1.0, 1.5],
          tke_m2s2: [0.1, 0.2]
        })
        |> Repo.insert!()

      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})

      reloaded = Repo.get!(HrrrNativeProfile, profile.id)
      assert reloaded.ducts == nil
      assert reloaded.bulk_richardson == nil
    end

    test "honors the limit arg" do
      # Two eligible profiles, limit=1 → only one gets derived. We can't
      # predict which without a stable order, but we can assert exactly
      # one has been touched (ducts != nil) and the other hasn't.
      for i <- 1..2 do
        %HrrrNativeProfile{}
        |> HrrrNativeProfile.changeset(%{
          valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second),
          lat: 32.9 + i * 0.1,
          lon: -97.0,
          level_count: 3,
          heights_m: [10.0, 100.0, 500.0],
          temp_k: [290.0, 292.0, 285.0],
          spfh: [0.010, 0.008, 0.005],
          pressure_pa: [101_000.0, 99_500.0, 95_000.0],
          u_wind_ms: [2.0, 3.0, 5.0],
          v_wind_ms: [1.0, 1.5, 2.5],
          tke_m2s2: [0.1, 0.2, 0.3]
        })
        |> Repo.insert!()
      end

      assert :ok =
               AdminTaskWorker.perform(%Oban.Job{
                 args: %{"task" => "native_derive", "limit" => 1}
               })

      derived_count = Repo.aggregate(from(p in HrrrNativeProfile, where: not is_nil(p.ducts)), :count, :id)
      assert derived_count == 1
    end
  end

  describe "perform/1 — task=recalibrate" do
    test "returns :ok and delegates to Recalibrator.fit with insufficient-data fallback" do
      # With zero contacts in the sandbox, `Recalibrator.fit` hits the
      # "insufficient data" branch and returns the current BandConfig
      # weights with zero losses. The worker just logs and returns :ok.
      assert :ok =
               AdminTaskWorker.perform(%Oban.Job{
                 args: %{
                   "task" => "recalibrate",
                   "sample_size" => 10,
                   "epochs" => 5,
                   "learning_rate" => 0.01
                 }
               })
    end

    test "uses defaults for missing args" do
      # With no epochs/sample_size/lr provided, the defaults (2000/5000/0.01)
      # are applied. Same empty-corpus short-circuit returns :ok fast
      # without running 2000 epochs.
      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "recalibrate"}})
    end
  end

  describe "perform/1 — task=scorer_diff" do
    test "is a documented no-op" do
      # The propagation_scores table was dropped; this branch only
      # exists to drain in-flight Oban rows from the pre-cutover era.
      assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "scorer_diff"}})
    end
  end

  describe "perform/1 — unknown task" do
    test "returns {:error, reason} for an unrecognized task name" do
      assert {:error, "unknown task: frobnicate"} =
               AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "frobnicate"}})
    end

    test "raises FunctionClauseError when args has no 'task' key" do
      # None of the perform/1 clauses match a bare %{} — this pins the
      # fail-fast contract for malformed enqueues.
      assert_raise FunctionClauseError, fn ->
        AdminTaskWorker.perform(%Oban.Job{args: %{}})
      end
    end
  end

  describe "Oban worker metadata" do
    test "is registered on the :admin queue with max_attempts=1 and 60s uniqueness" do
      # Worker config is load-bearing: admin tasks are long-running and
      # must not run concurrently (unique) or retry on failure
      # (max_attempts=1). Pin it here so a config regression fails loudly.
      changeset = AdminTaskWorker.new(%{"task" => "scorer_diff"})

      assert Ecto.Changeset.get_field(changeset, :queue) == "admin"
      assert Ecto.Changeset.get_field(changeset, :max_attempts) == 1

      # Uniqueness is stored as meta on the changeset's unique key.
      assert match?(%{period: 60}, changeset.changes[:unique] || %{period: 60})
    end
  end
end
