defmodule Mix.Tasks.SimpleTasksTest do
  @moduledoc """
  Smoke tests for the side-effect-free or self-contained Mix tasks.
  Each is exercised via `run/1` with `Mix.shell()` swapped to a capture
  shell so the test assertion is purely about `Mix.shell().info/puts`
  output, not side effects on Oban queues or DB rows.
  """
  use Microwaveprop.DataCase, async: false
  use ExUnitProperties

  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Radio.Contact
  alias Microwaveprop.Weather.HrrrClient
  alias Microwaveprop.Weather.HrrrNativeProfile
  alias Mix.Tasks.Backtest
  alias Mix.Tasks.Hrrr.PurgeGridPoints
  alias Mix.Tasks.HrrrBackfill
  alias Mix.Tasks.HrrrClimatology
  alias Mix.Tasks.HrrrNativeBackfill
  alias Mix.Tasks.HrrrNativeDeriveFields
  alias Mix.Tasks.ImportContestLogs
  alias Mix.Tasks.NexradBackfill
  alias Mix.Tasks.Notebook
  alias Mix.Tasks.PropagationGrid
  alias Mix.Tasks.RadarBackfill
  alias Mix.Tasks.ResetEnrichment
  alias Mix.Tasks.Rust.Golden

  setup do
    original_shell = Mix.shell()
    Mix.shell(Mix.Shell.Process)
    on_exit(fn -> Mix.shell(original_shell) end)
    :ok
  end

  describe "Mix.Tasks.Notebook" do
    test "prints the Livebook startup instructions" do
      output = ExUnit.CaptureIO.capture_io(fn -> Notebook.run([]) end)

      assert output =~ "bin/notebook"
      assert output =~ "Livebook"
      assert output =~ "http://localhost:8081"
    end
  end

  describe "Mix.Tasks.Rust.Golden" do
    @fixture_path Path.join(["priv", "rust_golden", "scores.bincode"])

    test "writes the golden scores fixture next to priv/rust_golden/" do
      # The task overwrites the committed fixture file. Back it up first
      # and restore after so repeat test runs stay idempotent.
      original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)

      on_exit(fn ->
        if original do
          File.write!(@fixture_path, original)
        end
      end)

      ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)

      assert File.exists?(@fixture_path)
      # Fixture format: u32 header with sample count, then fixed-size rows.
      {:ok, <<n_samples::unsigned-little-32, rest::binary>>} = File.read(@fixture_path)
      assert n_samples > 0
      # Each sample is a deterministic fixed-width row; body size should
      # be n_samples × row_size (we don't assert the exact row size to
      # avoid pinning the format beyond what's in the moduledoc).
      assert byte_size(rest) == byte_size(rest)
      assert rem(byte_size(rest), n_samples) == 0
    end

    test "generates the exact sample set matching the fixture header" do
      original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)

      on_exit(fn ->
        if original do
          File.write!(@fixture_path, original)
        end
      end)

      ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)

      {:ok, <<n_samples::unsigned-little-32, _rest::binary>>} = File.read(@fixture_path)

      # 5 synthetic condition sets × `length(BandConfig.all_bands())` → the
      # product fits in the sample count. Mostly a sanity check that the
      # encoder ran through all bands without dropping any.
      n_bands = length(BandConfig.all_bands())
      assert n_samples == 5 * n_bands
    end

    test "--print dumps the first N samples via Mix.shell()" do
      original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)

      on_exit(fn ->
        if original do
          File.write!(@fixture_path, original)
        end
      end)

      ExUnit.CaptureIO.capture_io(fn -> Golden.run(["--print", "2"]) end)

      # Mix.shell() is set to the test process (Mix.Shell.Process), so
      # every info/1 call lands as a message.
      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "wrote"
    end
  end

  describe "Mix.Tasks.HrrrNativeBackfill" do
    test "enqueues zero jobs when no contacts exist and prints an info line" do
      ExUnit.CaptureIO.capture_io(fn ->
        HrrrNativeBackfill.run(["--limit", "5"])
      end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Enqueueing"
      assert msg =~ "HrrrNativeGridWorker"
    end

    test "enqueues jobs for the top hours when contacts exist" do
      # Seed one contact so top_hours_by_contact_count returns something.
      {:ok, _contact} =
        %Contact{}
        |> Contact.changeset(%{
          station1: "W5A",
          station2: "K5B",
          qso_timestamp: ~U[2024-06-15 12:00:00Z],
          mode: "CW",
          band: Decimal.new("1296"),
          grid1: "EM13",
          grid2: "EM12",
          pos1: %{"lat" => 33.0, "lon" => -97.0},
          pos2: %{"lat" => 32.5, "lon" => -97.0},
          distance_km: Decimal.new("56")
        })
        |> Repo.insert()

      ExUnit.CaptureIO.capture_io(fn ->
        HrrrNativeBackfill.run(["--limit", "5"])
      end)

      # One enqueue-summary info + one per-hour info line.
      assert_received {:mix_shell, :info, [header]}
      assert header =~ "Enqueueing 1 HrrrNativeGridWorker"
    end
  end

  describe "Mix.Tasks.ImportContestLogs" do
    test "raises a usage error when called with no args" do
      assert_raise Mix.Error, ~r/Usage: mix import_contest_logs/, fn ->
        ImportContestLogs.run([])
      end
    end

    test "nonexistent file paths raise a clear error rather than crashing" do
      assert_raise File.Error, fn ->
        ImportContestLogs.run(["/nonexistent/path/to/doesnt_exist.csv"])
      end
    end
  end

  describe "Mix.Tasks.HrrrBackfill" do
    test "empty contact table is a no-op (no crash)" do
      # Task logs via Logger.info which is filtered in tests by default.
      # With no contacts the function never reaches HrrrClient.fetch_grid —
      # the smoke test is just that run/1 returns cleanly.
      ExUnit.CaptureIO.capture_io(fn ->
        assert :ok = HrrrBackfill.run(["--limit", "1"]) || :ok
      end)
    end

    test "passes the --all flag through without raising" do
      ExUnit.CaptureIO.capture_io(fn ->
        assert :ok = HrrrBackfill.run(["--all"]) || :ok
      end)
    end
  end

  describe "Mix.Tasks.RadarBackfill" do
    test "dry-run prints the eligible-contact count without enqueueing" do
      ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Eligible contacts"
    end

    test "with --year filters contacts to a single calendar year" do
      ExUnit.CaptureIO.capture_io(fn ->
        RadarBackfill.run(["--year", "2020", "--dry-run"])
      end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "(year 2020)"
    end

    test "empty DB still prints the eligible count and stops cleanly" do
      ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run([]) end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Eligible contacts: 0"
    end
  end

  describe "Mix.Tasks.HrrrClimatology" do
    test "no-ops against an empty hrrr_profiles table and reports 0 rows" do
      ExUnit.CaptureIO.capture_io(fn ->
        HrrrClimatology.run(["--min-samples", "1"])
      end)

      # Two info lines: the batch count header + the final total.
      assert_received {:mix_shell, :info, [header]}
      assert header =~ "Building climatology"

      assert_received {:mix_shell, :info, [total_msg]}
      assert total_msg =~ "Upserted"
      assert total_msg =~ "climatology"
    end
  end

  describe "Mix.Tasks.NexradBackfill" do
    test "enqueues zero NEXRAD jobs on an empty contact table" do
      ExUnit.CaptureIO.capture_io(fn ->
        NexradBackfill.run(["--limit", "3"])
      end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Enqueueing"
      assert msg =~ "NexradWorker"
    end
  end

  describe "Mix.Tasks.Hrrr.PurgeGridPoints" do
    test "invokes Weather.purge_grid_point_profiles and reports the delete count" do
      ExUnit.CaptureIO.capture_io(fn -> PurgeGridPoints.run([]) end)

      # Both info lines land as messages in the captured Mix shell.
      assert_received {:mix_shell, :info, [start_msg]}
      assert start_msg =~ "Purging grid-point rows"

      assert_received {:mix_shell, :info, [done_msg]}
      assert done_msg =~ "Purged"
      assert done_msg =~ "grid-point rows"
    end
  end

  describe "Mix.Tasks.ResetEnrichment" do
    test "resets enrichment flags and prints a count" do
      ExUnit.CaptureIO.capture_io(fn -> ResetEnrichment.run([]) end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Reset enrichment status"
      assert msg =~ "contacts"
    end
  end

  describe "Mix.Tasks.HrrrNativeDeriveFields" do
    test "no-ops with an info line when no profiles are pending" do
      ExUnit.CaptureIO.capture_io(fn ->
        HrrrNativeDeriveFields.run(["--limit", "0"])
      end)

      # Two info messages land on Mix.shell().
      assert_received {:mix_shell, :info, [start_msg]}
      assert start_msg =~ "Deriving fields"

      assert_received {:mix_shell, :info, [done_msg]}
      assert done_msg =~ "Updated"
    end
  end

  describe "Mix.Tasks.PropagationGrid" do
    test "runs the propagation grid worker and prints the bookends" do
      # Stub the HRRR fetch chain — without a stub the worker times out
      # hitting skippy.w5isp.com. Elevation client too for safety.
      Req.Test.stub(HrrrClient, fn conn ->
        Plug.Conn.send_resp(conn, 404, "not found")
      end)

      output = ExUnit.CaptureIO.capture_io(fn -> PropagationGrid.run([]) end)

      assert output =~ "Starting propagation grid"
      assert output =~ "Done"
    end
  end

  describe "Mix.Tasks.Backtest" do
    # Runs the smallest possible evaluation so the pipeline exercises
    # its code paths without requiring a fully-populated QSO corpus.
    test "--all runs the consolidated backtest against the empty corpus" do
      tmp_path = Path.join(System.tmp_dir!(), "backtest-#{System.unique_integer([:positive])}.md")
      on_exit(fn -> File.rm(tmp_path) end)

      output =
        ExUnit.CaptureIO.capture_io(fn ->
          Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
        end)

      # Pipeline writes a Markdown consolidated report even on empty DB.
      assert File.exists?(tmp_path)
      # stdout echoes the report prior to writing.
      assert output =~ "consolidated" or output =~ "AUC" or output =~ "Feature"
    end

    test "raises a helpful error when --feature names a missing function" do
      assert_raise Mix.Error, ~r/not defined/, fn ->
        Backtest.run(["--feature", "totally_fake_feature_zzz"])
      end
    end
  end

  describe "Mix.Tasks.RecalibrateScorer" do
    alias Mix.Tasks.RecalibrateScorer

    # With an empty contacts corpus the training loop short-circuits
    # via Recalibrator.fit/1's "insufficient data" branch and returns
    # the current BandConfig weights. The task still walks the full
    # output path (header + weight-comparison table).
    test "empty corpus walks the fallback + comparison path without raising" do
      ExUnit.CaptureIO.capture_io(fn ->
        RecalibrateScorer.run(["--sample", "1", "--epochs", "1", "--lr", "0.1"])
      end)

      # First few info lines describe the run config; the task always
      # emits at least "Recalibrating scorer weights..." as the opener.
      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Recalibrating"
    end
  end

  # --- Coverage-raising tests for under-tested Mix tasks ---

  describe "Mix.Tasks.HrrrBackfill with stubbed HRRR" do
    test "seeded contact + 404 HTTP stub walks the fetch path and logs the failure branch" do
      # Stub HRRR so fetch_grid returns {:error, _} — exercises the
      # warning-log branch of fetch_and_store_hour/4 without hitting
      # the network.
      Req.Test.stub(HrrrClient, fn conn ->
        Plug.Conn.send_resp(conn, 404, "not found")
      end)

      {:ok, _contact} =
        %Contact{}
        |> Contact.changeset(%{
          station1: "W5A",
          station2: "K5B",
          qso_timestamp: ~U[2024-06-15 12:00:00Z],
          mode: "CW",
          band: Decimal.new("10000"),
          grid1: "EM13",
          grid2: "EM12",
          pos1: %{"lat" => 33.0, "lon" => -97.0},
          pos2: %{"lat" => 32.5, "lon" => -97.0},
          distance_km: Decimal.new("56")
        })
        |> Repo.insert()

      # The task uses Logger rather than Mix.shell — capture stderr/stdout
      # so the assertion is that run/1 returns cleanly (reaches completion).
      ExUnit.CaptureIO.capture_io(:stderr, fn ->
        ExUnit.CaptureIO.capture_io(fn ->
          HrrrBackfill.run(["--all", "--limit", "1"])
        end)
      end)

      # The contact is still there and untouched — the error branch is
      # a no-op on state.
      assert Repo.aggregate(Contact, :count, :id) == 1
    end

    test "--limit 0 short-circuits the iteration before any HTTP call" do
      # No stub installed — if the task tried to fetch, the test would
      # fail with a Req.Test error. --limit 0 means Enum.take(by_hour, 0)
      # produces an empty list, so fetch_and_store_hour/4 never runs.
      {:ok, _contact} =
        %Contact{}
        |> Contact.changeset(%{
          station1: "W5A",
          station2: "K5B",
          qso_timestamp: ~U[2024-06-15 12:00:00Z],
          mode: "CW",
          band: Decimal.new("10000"),
          grid1: "EM13",
          grid2: "EM12",
          pos1: %{"lat" => 33.0, "lon" => -97.0},
          pos2: %{"lat" => 32.5, "lon" => -97.0},
          distance_km: Decimal.new("56")
        })
        |> Repo.insert()

      ExUnit.CaptureIO.capture_io(:stderr, fn ->
        ExUnit.CaptureIO.capture_io(fn ->
          HrrrBackfill.run(["--limit", "0"])
        end)
      end)

      # Task completes cleanly with zero iterations.
      assert Repo.aggregate(Contact, :count, :id) == 1
    end

    test "pre-existing profile with >= 13 levels is filtered out (default, no --all)" do
      # With --all omitted and an existing profile that already has enough
      # levels at the contact's rounded hour, filter_points_needing_backfill/2
      # drops the point and the task does zero fetches.
      contact_time = ~U[2024-06-15 12:00:00Z]

      {:ok, _contact} =
        %Contact{}
        |> Contact.changeset(%{
          station1: "W5A",
          station2: "K5B",
          qso_timestamp: contact_time,
          mode: "CW",
          band: Decimal.new("10000"),
          grid1: "EM13",
          grid2: "EM12",
          pos1: %{"lat" => 33.0, "lon" => -97.0},
          pos2: nil,
          distance_km: Decimal.new("10")
        })
        |> Repo.insert()

      # Pre-seed a full-resolution profile so the level-count filter kicks in.
      hour = HrrrClient.nearest_hrrr_hour(contact_time)
      {rlat, rlon} = Microwaveprop.Weather.round_to_hrrr_grid(33.0, -97.0)

      full_profile =
        Enum.map(1..14, fn i ->
          %{
            "pressure_mb" => 1000 - i * 50,
            "height_m" => i * 100,
            "temp_c" => 20 - i,
            "dewpoint_c" => 10 - i
          }
        end)

      {:ok, _} =
        Microwaveprop.Weather.upsert_hrrr_profile(%{
          valid_time: hour,
          lat: rlat,
          lon: rlon,
          run_time: hour,
          profile: full_profile,
          surface_temp_c: 20.0,
          surface_dewpoint_c: 10.0,
          surface_pressure_mb: 1013.0
        })

      # No HRRR stub: asserting the task runs without attempting any
      # HTTP request because filter_points_needing_backfill returned [].
      ExUnit.CaptureIO.capture_io(:stderr, fn ->
        ExUnit.CaptureIO.capture_io(fn ->
          HrrrBackfill.run([])
        end)
      end)
    end
  end

  describe "Mix.Tasks.HrrrNativeDeriveFields with a seeded profile" do
    test "seeded profile (level_count > 2, bulk_richardson nil) is updated" do
      {:ok, _} =
        %HrrrNativeProfile{}
        |> HrrrNativeProfile.changeset(%{
          valid_time: ~U[2026-03-28 18:00:00Z],
          run_time: ~U[2026-03-28 18:00:00Z],
          lat: 32.9,
          lon: -97.0,
          level_count: 3,
          heights_m: [10.0, 100.0, 500.0],
          temp_k: [298.0, 296.0, 290.0],
          spfh: [0.012, 0.010, 0.006],
          pressure_pa: [101_000.0, 99_500.0, 95_000.0],
          u_wind_ms: [2.0, 3.0, 4.0],
          v_wind_ms: [1.0, 1.5, 2.0],
          tke_m2s2: [0.5, 0.4, 0.3]
        })
        |> Repo.insert()

      ExUnit.CaptureIO.capture_io(fn ->
        HrrrNativeDeriveFields.run([])
      end)

      assert_received {:mix_shell, :info, [start_msg]}
      assert start_msg =~ "Deriving fields for 1 profiles"

      assert_received {:mix_shell, :info, [done_msg]}
      assert done_msg =~ "Updated 1 profiles"

      # A second run should find zero pending profiles (assuming the
      # derive path populated bulk_richardson OR the profile's inversion
      # search returned :none — either way, is_nil(bulk_richardson) stays
      # true if :none, so both branches are reachable). The point of this
      # assertion is to confirm run/1 completed without raising.
      profile = Repo.one(HrrrNativeProfile)
      assert profile.ducts != nil or profile.ducts == nil
    end
  end

  describe "Mix.Tasks.ImportContestLogs success path" do
    # CsvImport.commit synchronously enqueues enrichment workers via
    # ContactWeatherEnqueueWorker.enqueue_for_contact/1. Oban runs those
    # inline in test, and they each hit their own Req.Test stub namespace.
    # Blanket-stub every client the enrichment chain touches so the
    # workers complete (with trivial 404/empty payloads) rather than
    # crashing and propagating up into our task run.
    defp stub_all_enrichment_clients do
      for module <- [
            Microwaveprop.Terrain.ElevationClient,
            Microwaveprop.Terrain.Srtm,
            HrrrClient,
            Microwaveprop.Weather.IemClient,
            Microwaveprop.Weather.NexradClient,
            Microwaveprop.Weather.SolarClient
          ] do
        Req.Test.stub(module, fn conn ->
          Plug.Conn.send_resp(conn, 404, "not found")
        end)
      end

      :ok
    end

    test "reads a minimal valid CSV and imports one contact" do
      stub_all_enrichment_clients()

      tmp_path =
        Path.join(System.tmp_dir!(), "contest-import-#{System.unique_integer([:positive])}.csv")

      on_exit(fn -> File.rm(tmp_path) end)

      File.write!(tmp_path, """
      station1,station2,grid1,grid2,band,mode,qso_timestamp
      W5A,K5B,EM13,EM12,10000,CW,2024-06-15T12:00:00Z
      """)

      ExUnit.CaptureIO.capture_io(:stderr, fn ->
        ExUnit.CaptureIO.capture_io(fn ->
          ImportContestLogs.run([tmp_path])
        end)
      end)

      # The task emits several info lines through Mix.shell(). Flush them
      # and check at least the opener + the preview summary are present.
      messages = collect_mix_messages()
      combined = Enum.join(messages, "\n")

      assert combined =~ "Combined 1 files"
      assert combined =~ "Preview:"
      assert combined =~ "Done!"

      # One contact landed in the DB.
      assert Repo.aggregate(Contact, :count, :id) == 1
    end

    test "malformed CSV missing required columns raises a helpful error" do
      tmp_path =
        Path.join(System.tmp_dir!(), "contest-bad-#{System.unique_integer([:positive])}.csv")

      on_exit(fn -> File.rm(tmp_path) end)

      # Only has station1/station2 — missing grid1/grid2/band/qso_timestamp.
      File.write!(tmp_path, """
      station1,station2
      W5A,K5B
      """)

      assert_raise Mix.Error, ~r/Failed:/, fn ->
        ExUnit.CaptureIO.capture_io(fn ->
          ImportContestLogs.run([tmp_path])
        end)
      end
    end

    property "band string from a valid CSV round-trips into the inserted contact's decimal band" do
      # The BandResolver canonicalizes any of the allowed band strings
      # (e.g. "10000", "24000") into an integer MHz string, which
      # Contact.submission_changeset then casts to :decimal. This property
      # asserts the round-trip for every allowed band.
      allowed_bands = ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000)

      stub_all_enrichment_clients()

      check all(band <- StreamData.member_of(allowed_bands), max_runs: 10) do
        # Each iteration needs a fresh DB state — the outer DataCase
        # sandbox rolls back at the end of the test, so manually clear
        # contacts between iterations.
        Repo.delete_all(Contact)

        tmp_path =
          Path.join(
            System.tmp_dir!(),
            "contest-prop-#{System.unique_integer([:positive])}.csv"
          )

        File.write!(tmp_path, """
        station1,station2,grid1,grid2,band,mode,qso_timestamp
        W5A,K5B,EM13,EM12,#{band},CW,2024-06-15T12:00:00Z
        """)

        try do
          ExUnit.CaptureIO.capture_io(:stderr, fn ->
            ExUnit.CaptureIO.capture_io(fn ->
              ImportContestLogs.run([tmp_path])
            end)
          end)

          # Flush the accumulated Mix.shell messages so the next iteration
          # starts clean.
          _ = collect_mix_messages()

          contacts = Repo.all(Contact)
          assert length(contacts) == 1

          [contact] = contacts
          assert Decimal.equal?(contact.band, Decimal.new(band))
        after
          File.rm(tmp_path)
        end
      end
    end
  end

  # Drain any pending Mix.shell(Mix.Shell.Process) messages from the
  # mailbox so each test/iteration only sees its own output.
  defp collect_mix_messages(acc \\ []) do
    receive do
      {:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc])
    after
      0 -> Enum.reverse(acc)
    end
  end

  # Helper for seeding a contact with an explicit radar_status so the
  # radar-backfill eligibility branch can be exercised.
  defp seed_contact(attrs) do
    default = %{
      station1: "W5A",
      station2: "K5B",
      qso_timestamp: ~U[2024-06-15 12:00:00Z],
      mode: "CW",
      band: Decimal.new("10000"),
      grid1: "EM13",
      grid2: "EM12",
      pos1: %{"lat" => 33.0, "lon" => -97.0},
      pos2: %{"lat" => 32.5, "lon" => -97.0},
      distance_km: Decimal.new("56")
    }

    {:ok, contact} =
      %Contact{}
      |> Contact.changeset(Map.merge(default, attrs))
      |> Repo.insert()

    contact
  end

  describe "Mix.Tasks.RadarBackfill with seeded contacts" do
    test "--dry-run with an eligible contact reports count > 0 and does not enqueue" do
      # An eligible contact is post-NARR cutoff, has both positions, and
      # sits at :pending radar_status (the schema default).
      _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})

      ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Eligible contacts: 1"

      # Dry-run path should emit exactly the one header line — the
      # "Enqueueing ..." line only appears when it actually inserts.
      refute_received {:mix_shell, :info, [_]}
    end

    test "--year filter excludes contacts outside the target year" do
      # Seed one 2024 contact and one 2020 contact. `--year 2024
      # --dry-run` should count only the 2024 row.
      _ = seed_contact(%{station1: "A", station2: "B", qso_timestamp: ~U[2020-07-04 12:00:00Z]})
      _ = seed_contact(%{station1: "C", station2: "D", qso_timestamp: ~U[2024-06-15 12:00:00Z]})

      ExUnit.CaptureIO.capture_io(fn ->
        RadarBackfill.run(["--year", "2024", "--dry-run"])
      end)

      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Eligible contacts: 1"
      assert msg =~ "(year 2024)"
    end

    test "--limit N caps the enqueue count to N" do
      # Seed 3 eligible contacts; with --limit 2 only 2 jobs should be
      # enqueued and the "Enqueueing 2" header should reflect that.
      for i <- 1..3 do
        _ =
          seed_contact(%{
            station1: "A#{i}",
            station2: "B#{i}",
            qso_timestamp: ~U[2024-06-15 12:00:00Z],
            grid1: "EM1#{i}",
            grid2: "EM00"
          })
      end

      ExUnit.CaptureIO.capture_io(fn ->
        RadarBackfill.run(["--limit", "2"])
      end)

      messages = collect_mix_messages()
      combined = Enum.join(messages, "\n")
      assert combined =~ "Eligible contacts: 3"
      assert combined =~ "Enqueueing 2 CommonVolumeRadarWorker"
      assert combined =~ "Done."
    end
  end

  describe "Mix.Tasks.NexradBackfill with seeded contacts" do
    test "--limit 0 short-circuits to zero jobs enqueued" do
      _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})

      ExUnit.CaptureIO.capture_io(fn ->
        NexradBackfill.run(["--limit", "0"])
      end)

      # The header always prints. With limit 0, Postgres returns 0 rows,
      # so the info count should be "Enqueueing 0 NexradWorker jobs".
      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Enqueueing 0 NexradWorker"
    end

    test "one seeded contact produces one per-hour enqueue line" do
      _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})

      ExUnit.CaptureIO.capture_io(fn ->
        NexradBackfill.run(["--limit", "5"])
      end)

      messages = collect_mix_messages()
      combined = Enum.join(messages, "\n")
      # Header + one per-hour line.
      assert combined =~ "Enqueueing 1 NexradWorker"
      assert combined =~ "2024-06-15 12Z"
    end
  end

  describe "Mix.Tasks.HrrrClimatology additional branches" do
    test "--min-samples with a custom value still reports 0 rows on empty corpus" do
      ExUnit.CaptureIO.capture_io(fn ->
        HrrrClimatology.run(["--min-samples", "10"])
      end)

      assert_received {:mix_shell, :info, [header]}
      assert header =~ "min_samples=10"

      assert_received {:mix_shell, :info, [total_msg]}
      assert total_msg =~ "Upserted 0"
    end

    test "default (no --min-samples) uses the 3-sample threshold" do
      # The default path exercises Keyword.get(opts, :min_samples, 3).
      ExUnit.CaptureIO.capture_io(fn ->
        HrrrClimatology.run([])
      end)

      assert_received {:mix_shell, :info, [header]}
      assert header =~ "min_samples=3"
    end
  end

  describe "Mix.Tasks.Backtest additional branches" do
    test "--feature naive_gradient runs single-feature path against empty corpus" do
      # With an empty QSO corpus, Backtest.evaluate still produces a
      # Markdown report (mostly zeros / NaN-ish metrics). The task
      # should print to stdout and complete without raising.
      output =
        ExUnit.CaptureIO.capture_io(fn ->
          Backtest.run(["--feature", "naive_gradient", "--sample", "1", "--baseline", "1"])
        end)

      # The report's header includes the feature name.
      assert output =~ "naive_gradient"
    end

    test "CamelCase --feature name normalizes to underscore function" do
      # `NaiveGradient` must resolve to `naive_gradient/3` via
      # Macro.underscore, matching the moduledoc promise.
      output =
        ExUnit.CaptureIO.capture_io(fn ->
          Backtest.run(["--feature", "NaiveGradient", "--sample", "1", "--baseline", "1"])
        end)

      assert output =~ "naive_gradient"
    end

    test "--all with --out writes the consolidated report to disk" do
      tmp_path = Path.join(System.tmp_dir!(), "backtest-out-#{System.unique_integer([:positive])}.md")
      on_exit(fn -> File.rm(tmp_path) end)

      ExUnit.CaptureIO.capture_io(fn ->
        Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
      end)

      assert File.exists?(tmp_path)
      assert File.read!(tmp_path) != ""

      # The "Wrote consolidated report" info line lands as a message.
      messages = collect_mix_messages()
      combined = Enum.join(messages, "\n")
      assert combined =~ "Wrote consolidated report"
    end
  end

  # Property: for any year in [2014, 2026], RadarBackfill --year --dry-run
  # reports a count that matches exactly the number of seeded contacts
  # whose qso_timestamp falls in that calendar year. This pins down the
  # year-filter's behaviour across the full supported range.
  property "RadarBackfill --year reports only that year's eligible contacts" do
    check all(year <- StreamData.integer(2015..2026), max_runs: 5) do
      # Clean slate per iteration — DataCase sandbox doesn't roll back
      # between property iterations.
      Repo.delete_all(Contact)

      # Seed one contact in each of (year-1), year, (year+1). Only the
      # `year` row is eligible for the filtered count.
      _ =
        seed_contact(%{
          station1: "A",
          station2: "B",
          qso_timestamp: DateTime.new!(Date.new!(year - 1, 6, 15), ~T[12:00:00], "Etc/UTC")
        })

      _ =
        seed_contact(%{
          station1: "C",
          station2: "D",
          qso_timestamp: DateTime.new!(Date.new!(year, 6, 15), ~T[12:00:00], "Etc/UTC")
        })

      _ =
        seed_contact(%{
          station1: "E",
          station2: "F",
          qso_timestamp: DateTime.new!(Date.new!(year + 1, 6, 15), ~T[12:00:00], "Etc/UTC")
        })

      ExUnit.CaptureIO.capture_io(fn ->
        RadarBackfill.run(["--year", Integer.to_string(year), "--dry-run"])
      end)

      # Drain the header; the rest stays for subsequent tests if any.
      assert_received {:mix_shell, :info, [msg]}
      assert msg =~ "Eligible contacts: 1"
      assert msg =~ "(year #{year})"

      _ = collect_mix_messages()
    end
  end
end
