defmodule Microwaveprop.Radio.CsvImportTest do
  use Microwaveprop.DataCase, async: true
  use Oban.Testing, repo: Microwaveprop.Repo

  alias Microwaveprop.Radio.Contact
  alias Microwaveprop.Radio.CsvImport
  alias Microwaveprop.Terrain.ElevationClient
  alias Microwaveprop.Weather.HrrrClient
  alias Microwaveprop.Weather.IemClient

  setup do
    Req.Test.stub(IemClient, fn conn ->
      case conn.request_path do
        "/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
        _ -> Req.Test.json(conn, %{"profiles" => []})
      end
    end)

    Req.Test.stub(HrrrClient, fn conn ->
      Plug.Conn.send_resp(conn, 404, "not found")
    end)

    Req.Test.stub(ElevationClient, fn conn ->
      params = Plug.Conn.fetch_query_params(conn).query_params
      lat_count = params["latitude"] |> String.split(",") |> length()
      Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
    end)

    :ok
  end

  @valid_header "station1,station2,grid1,grid2,band,mode,qso_timestamp"
  @valid_row "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
  @submitter "test@example.com"

  describe "import/2 with empty or missing data" do
    test "returns error for empty string" do
      assert {:error, :empty_csv} = CsvImport.import("", @submitter)
    end

    test "returns error for whitespace-only string" do
      assert {:error, :empty_csv} = CsvImport.import("   \n  \n  ", @submitter)
    end

    test "returns error for header-only CSV" do
      assert {:error, :no_data_rows} = CsvImport.import(@valid_header, @submitter)
    end

    test "returns error for header plus blank lines only" do
      csv = @valid_header <> "\n\n\n"
      assert {:error, :no_data_rows} = CsvImport.import(csv, @submitter)
    end
  end

  describe "import/2 with valid data" do
    test "imports a single valid row" do
      csv = @valid_header <> "\n" <> @valid_row

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert %Contact{} = contact
      assert contact.station1 == "W5XD"
      assert contact.station2 == "K5TR"
      assert contact.grid1 == "EM12"
      assert contact.grid2 == "EM00"
      assert contact.mode == "CW"
      assert contact.user_submitted == true
      assert contact.submitter_email == @submitter
      assert contact.pos1
      assert contact.pos2
      assert contact.distance_km
    end

    test "imports multiple valid rows" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
          ],
          "\n"
        )

      assert {:ok, %{imported: imported, errors: []}} = CsvImport.import(csv, @submitter)
      assert length(imported) == 2
    end

    test "trims whitespace from fields" do
      csv = @valid_header <> "\n" <> " W5XD , K5TR , EM12 , EM00 , 10000 , CW , 2026-03-28T18:00:00Z "

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
      assert contact.station2 == "K5TR"
    end

    test "handles Windows line endings" do
      csv = @valid_header <> "\r\n" <> @valid_row <> "\r\n"

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
    end

    test "skips blank lines between data rows" do
      csv =
        Enum.join(
          [
            @valid_header,
            @valid_row,
            "",
            "",
            "N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
          ],
          "\n"
        )

      assert {:ok, %{imported: imported, errors: []}} = CsvImport.import(csv, @submitter)
      assert length(imported) == 2
    end

    test "contacts are persisted to the database" do
      csv = @valid_header <> "\n" <> @valid_row

      assert {:ok, _result} = CsvImport.import(csv, @submitter)
      assert Repo.aggregate(Contact, :count) == 1
    end

    test "accepts a 6-column CSV without the mode column" do
      csv =
        "station1,station2,grid1,grid2,band,qso_timestamp\n" <>
          "W5XD,K5TR,EM12,EM00,10000,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
      assert contact.mode == nil
    end

    test "accepts a 7-column CSV with a blank mode value" do
      csv =
        "station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
          "W5XD,K5TR,EM12,EM00,10000,,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.mode == nil
    end
  end

  describe "import/2 with invalid data" do
    test "reports wrong column count with row number" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert Enum.any?(errors, &String.contains?(&1, "column"))
    end

    test "reports invalid band" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert errors != []
    end

    test "reports invalid grid square" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert errors != []
    end

    test "reports invalid mode" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,RTTY,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert errors != []
    end

    test "reports missing required fields" do
      csv = @valid_header <> "\n" <> ",K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert errors != []
    end

    test "reports invalid timestamp" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,not-a-date"

      assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
      assert errors != []
    end
  end

  describe "import/2 with mixed valid and invalid rows" do
    test "imports valid rows and reports invalid rows" do
      csv =
        Enum.join(
          [
            @valid_header,
            @valid_row,
            "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z",
            "N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
          ],
          "\n"
        )

      assert {:ok, %{imported: imported, errors: errors}} = CsvImport.import(csv, @submitter)
      assert length(imported) == 2
      assert [{3, _error_msgs}] = errors
    end

    test "imports row with US date format" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,6/15/2024 2:30 PM"

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.qso_timestamp == ~U[2024-06-15 14:30:00Z]
    end

    test "imports row with space-separated datetime" do
      csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2024-06-15 14:30"

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.qso_timestamp == ~U[2024-06-15 14:30:00Z]
    end

    test "row numbers account for header and blank lines" do
      csv =
        Enum.join(
          [
            @valid_header,
            @valid_row,
            "",
            "bad row with wrong columns",
            "N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
          ],
          "\n"
        )

      assert {:ok, %{imported: imported, errors: errors}} = CsvImport.import(csv, @submitter)
      assert length(imported) == 2
      # Row 4 is the bad row (header=1, data=2, blank=3, bad=4, good=5)
      assert [{4, _error_msgs}] = errors
    end
  end

  describe "import/2 with header-driven column mapping" do
    # The /contacts export produces a rich CSV with label-style headers and
    # extra columns (id, distance_km, status fields, inserted_at). The import
    # must read the header row, match by field name / label, and ignore
    # anything it doesn't recognize so export → import round-trips.

    test "accepts /contacts export format (labels + extra columns + different order)" do
      # Mirrors the live_table export: 12 columns, labels like "Station 1",
      # grids interleaved (station1, grid1, station2, grid2…), and extra
      # columns the importer should ignore.
      csv = """
      ID,Station 1,Grid 1,Station 2,Grid 2,Band,Mode,Distance (km),Enriched,Flag,QSO (UTC),Added (UTC)
      abc-123,W5XD,EM12,K5TR,EM00,10000,CW,295,Complete,,2026-03-28 18:00,2026-03-28 18:05
      """

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
      assert contact.station2 == "K5TR"
      assert contact.grid1 == "EM12"
      assert contact.grid2 == "EM00"
      assert contact.mode == "CW"
    end

    test "accepts arbitrary column order" do
      csv = """
      qso_timestamp,band,mode,grid2,grid1,station2,station1
      2026-03-28T18:00:00Z,10000,CW,EM00,EM12,K5TR,W5XD
      """

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
      assert contact.grid1 == "EM12"
    end

    test "is case-insensitive on header names" do
      csv = """
      STATION1,STATION2,GRID1,GRID2,BAND,MODE,QSO_TIMESTAMP
      W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z
      """

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
    end

    test "ignores unknown columns" do
      csv = """
      station1,notes,station2,grid1,ignored_col,grid2,band,mode,qso_timestamp
      W5XD,some note,K5TR,EM12,xyz,EM00,10000,CW,2026-03-28T18:00:00Z
      """

      assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
      assert contact.station1 == "W5XD"
    end

    test "rejects when a required column header is missing" do
      # No qso_timestamp column at all.
      csv = """
      station1,station2,grid1,grid2,band,mode
      W5XD,K5TR,EM12,EM00,10000,CW
      """

      assert {:error, {:missing_required_columns, missing}} =
               CsvImport.import(csv, @submitter)

      assert "qso_timestamp" in missing
    end
  end

  describe "normalize_timestamp/1" do
    test "parses ISO 8601 with Z" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15T14:30:00Z")
    end

    test "parses ISO 8601 with offset" do
      assert {:ok, _} = CsvImport.normalize_timestamp("2024-06-15T14:30:00+00:00")
    end

    test "parses ISO without timezone" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15T14:30:00")
    end

    test "parses space-separated datetime" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15 14:30:00")
    end

    test "parses space-separated without seconds" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024-06-15 14:30")
    end

    test "parses date only" do
      assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("2024-06-15")
    end

    test "parses US date with AM/PM" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 2:30 PM")
    end

    test "parses US date with AM" do
      assert {:ok, "2024-06-15T09:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 9:30 AM")
    end

    test "parses 12:00 AM as midnight" do
      assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024 12:00 AM")
    end

    test "parses 12:00 PM as noon" do
      assert {:ok, "2024-06-15T12:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024 12:00 PM")
    end

    test "parses zero-padded US date with AM/PM" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06/15/2024 02:30 PM")
    end

    test "parses US date 24h" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06/15/2024 14:30")
    end

    test "parses US date 24h unpadded" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("6/15/2024 14:30")
    end

    test "parses US date with dashes" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06-15-2024 14:30")
    end

    test "parses compact with T" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("20240615T143000")
    end

    test "parses compact with space" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("20240615 1430")
    end

    test "trims whitespace" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("  2024-06-15T14:30:00Z  ")
    end

    test "returns error for garbage" do
      assert {:error, "qso_timestamp could not be parsed: garbage"} =
               CsvImport.normalize_timestamp("garbage")
    end

    test "returns error for empty string" do
      assert {:error, _} = CsvImport.normalize_timestamp("")
    end

    test "parses US date with dots" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06.15.2024 14:30")
    end

    test "parses ISO with slashes" do
      assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024/06/15 14:30")
    end

    test "parses US date only" do
      assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024")
    end
  end

  describe "normalize_timestamp/1 property tests" do
    use ExUnitProperties

    property "ISO 8601 format always parses successfully" do
      check all(
              year <- integer(2000..2030),
              month <- integer(1..12),
              day <- integer(1..28),
              hour <- integer(0..23),
              minute <- integer(0..59),
              second <- integer(0..59)
            ) do
        y = String.pad_leading(to_string(year), 4, "0")
        m = String.pad_leading(to_string(month), 2, "0")
        d = String.pad_leading(to_string(day), 2, "0")
        h = String.pad_leading(to_string(hour), 2, "0")
        mi = String.pad_leading(to_string(minute), 2, "0")
        s = String.pad_leading(to_string(second), 2, "0")

        iso = "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"
        assert {:ok, ^iso} = CsvImport.normalize_timestamp(iso)
      end
    end

    property "US date format with 24h time always parses" do
      check all(
              year <- integer(2000..2030),
              month <- integer(1..12),
              day <- integer(1..28),
              hour <- integer(0..23),
              minute <- integer(0..59),
              sep <- member_of(["/", "-", "."])
            ) do
        input = "#{month}#{sep}#{day}#{sep}#{year} #{hour}:#{String.pad_leading(to_string(minute), 2, "0")}"
        assert {:ok, result} = CsvImport.normalize_timestamp(input)
        assert String.ends_with?(result, "Z")
        {:ok, dt, _} = DateTime.from_iso8601(result)
        assert dt.year == year
        assert dt.month == month
        assert dt.day == day
      end
    end

    property "US date with AM/PM parses correctly" do
      check all(
              year <- integer(2000..2030),
              month <- integer(1..12),
              day <- integer(1..28),
              hour_12 <- integer(1..12),
              minute <- integer(0..59),
              ampm <- member_of(["AM", "PM", "am", "pm"])
            ) do
        input = "#{month}/#{day}/#{year} #{hour_12}:#{String.pad_leading(to_string(minute), 2, "0")} #{ampm}"
        assert {:ok, result} = CsvImport.normalize_timestamp(input)
        {:ok, dt, _} = DateTime.from_iso8601(result)

        expected_hour =
          case {hour_12, String.upcase(ampm)} do
            {12, "AM"} -> 0
            {12, "PM"} -> 12
            {h, "PM"} -> h + 12
            {h, "AM"} -> h
          end

        assert dt.hour == expected_hour
        assert dt.minute == minute
      end
    end

    property "all parsed results are valid ISO 8601 UTC datetimes" do
      formats =
        StreamData.member_of([
          fn y, m, d, h, mi -> "#{y}-#{m}-#{d}T#{h}:#{mi}:00Z" end,
          fn y, m, d, h, mi -> "#{y}-#{m}-#{d} #{h}:#{mi}" end,
          fn y, m, d, _h, _mi -> "#{y}-#{m}-#{d}" end,
          fn y, m, d, h, mi -> "#{m}/#{d}/#{y} #{h}:#{mi}" end
        ])

      check all(
              year <- integer(2000..2030),
              month <- integer(1..12),
              day <- integer(1..28),
              hour <- integer(0..23),
              minute <- integer(0..59),
              fmt <- formats
            ) do
        y = String.pad_leading(to_string(year), 4, "0")
        m = String.pad_leading(to_string(month), 2, "0")
        d = String.pad_leading(to_string(day), 2, "0")
        h = String.pad_leading(to_string(hour), 2, "0")
        mi = String.pad_leading(to_string(minute), 2, "0")

        input = fmt.(y, m, d, h, mi)

        case CsvImport.normalize_timestamp(input) do
          {:ok, result} ->
            assert String.ends_with?(result, "Z")
            assert {:ok, _, _} = DateTime.from_iso8601(result)

          {:error, _} ->
            :ok
        end
      end
    end

    property "garbage input never returns ok" do
      check all(garbage <- string(:alphanumeric, min_length: 1, max_length: 5)) do
        case CsvImport.normalize_timestamp(garbage) do
          {:ok, _} -> flunk("Parsed garbage: #{inspect(garbage)}")
          {:error, _} -> :ok
        end
      end
    end
  end

  describe "preview/2" do
    test "returns empty/no-data errors like import/2" do
      assert {:error, :empty_csv} = CsvImport.preview("", @submitter)
      assert {:error, :no_data_rows} = CsvImport.preview(@valid_header, @submitter)
    end

    test "classifies a valid row as valid and nothing is inserted" do
      csv = @valid_header <> "\n" <> @valid_row

      assert {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert [%{row_num: 2, attrs: attrs, timestamp: %DateTime{}}] = preview.valid
      assert attrs["station1"] == "W5XD"
      assert preview.invalid == []
      assert preview.duplicates == []
      assert preview.refinements == []
      assert preview.total_rows == 1
      assert preview.submitter_email == @submitter
      assert Repo.aggregate(Contact, :count) == 0
    end

    test "classifies bad rows as invalid" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z",
            "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
          ],
          "\n"
        )

      assert {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert preview.valid == []
      assert length(preview.invalid) == 2
      assert Enum.all?(preview.invalid, &(&1.messages != []))
    end

    test "flags an existing DB contact as a duplicate" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)

      assert {:ok, second} = CsvImport.preview(csv, @submitter)
      assert second.valid == []
      assert second.refinements == []
      assert [%{row_num: 2, reason: :existing_contact}] = second.duplicates
    end

    test "treats direction swap as the same contact" do
      csv1 = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
      {:ok, p1} = CsvImport.preview(csv1, @submitter)
      {:ok, _} = CsvImport.commit(p1.valid)

      # Same contact from K5TR's side — stations and grids swapped.
      csv2 = @valid_header <> "\n" <> "K5TR,W5XD,EM00,EM12,10000,CW,2026-03-28T18:20:00Z"
      {:ok, p2} = CsvImport.preview(csv2, @submitter)
      assert p2.valid == []
      assert [%{reason: :existing_contact}] = p2.duplicates
    end

    test "flags an earlier row in the same upload as a duplicate" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:30:00Z"
          ],
          "\n"
        )

      {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert [%{row_num: 2}] = preview.valid
      assert [%{row_num: 3, reason: :earlier_in_upload}] = preview.duplicates
    end

    test "rows more than an hour apart are not duplicates" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T19:30:00Z"
          ],
          "\n"
        )

      assert {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert length(preview.valid) == 2
      assert preview.duplicates == []
    end

    test "different bands are never duplicates" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "W5XD,K5TR,EM12,EM00,24000,CW,2026-03-28T18:00:00Z"
          ],
          "\n"
        )

      assert {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert length(preview.valid) == 2
    end

    test "different grids are not duplicates (rover moved)" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "W5XD,K5TR,EM13,EM00,10000,CW,2026-03-28T18:15:00Z"
          ],
          "\n"
        )

      assert {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert length(preview.valid) == 2
      assert preview.refinements == []
    end

    test "upload with longer grid than existing routes to refinements" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid)

      refined_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"

      assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter)
      assert p2.valid == []
      assert p2.duplicates == []
      assert [ref] = p2.refinements
      assert ref.row_num == 2
      assert ref.existing_id == existing.id
      assert ref.changes == %{grid1: "EM12KP37"}
    end

    test "direction-swapped refinement maps changes to existing orientation" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid)

      # Upload as K5TR's side with more-precise grids
      swapped_csv =
        @valid_header <> "\n" <> "K5TR,W5XD,EM00bb,EM12aa,10000,CW,2026-03-28T18:05:00Z"

      assert {:ok, p2} = CsvImport.preview(swapped_csv, @submitter)
      assert p2.valid == []
      assert [ref] = p2.refinements
      assert ref.existing_id == existing.id
      assert ref.changes == %{grid1: "EM12AA", grid2: "EM00BB"}
    end

    test "upload with mode filling in missing mode routes to refinements" do
      {:ok, existing} =
        Microwaveprop.Radio.create_contact(%{
          station1: "W5XD",
          station2: "K5TR",
          grid1: "EM12",
          grid2: "EM00",
          band: "10000",
          qso_timestamp: ~U[2026-03-28 18:00:00Z],
          submitter_email: @submitter
        })

      refined_csv = @valid_header <> "\n" <> @valid_row

      assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter)
      assert p2.valid == []
      assert [ref] = p2.refinements
      assert ref.existing_id == existing.id
      assert ref.changes == %{mode: "CW"}
    end

    test "contradictory grid (same 4-char prefix, different 6th char) is a duplicate not refinement" do
      csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12kp,EM00,10000,CW,2026-03-28T18:00:00Z"

      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [_existing]}} = CsvImport.commit(preview.valid)

      contradictory_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12qq,EM00,10000,CW,2026-03-28T18:05:00Z"

      assert {:ok, p2} = CsvImport.preview(contradictory_csv, @submitter)
      assert p2.valid == []
      assert p2.refinements == []
      assert [%{reason: :existing_contact}] = p2.duplicates
    end

    test "contradictory mode is a duplicate not refinement" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)

      mismatched_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,SSB,2026-03-28T18:05:00Z"

      assert {:ok, p2} = CsvImport.preview(mismatched_csv, @submitter)
      assert p2.valid == []
      assert p2.refinements == []
      assert [%{reason: :existing_contact}] = p2.duplicates
    end

    test "rover with different 4-char prefix still classifies as valid" do
      csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"

      {:ok, preview} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)

      rover_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM13,EM00,10000,CW,2026-03-28T18:15:00Z"

      assert {:ok, p2} = CsvImport.preview(rover_csv, @submitter)
      assert length(p2.valid) == 1
      assert p2.refinements == []
      assert p2.duplicates == []
    end
  end

  describe "commit/1" do
    test "inserts each valid row and returns the created contacts" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z",
            "N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
          ],
          "\n"
        )

      {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert {:ok, %{imported: [a, b], errors: []}} = CsvImport.commit(preview.valid)
      assert %Contact{station1: "W5XD"} = a
      assert %Contact{station1: "N5AC"} = b
      assert Repo.aggregate(Contact, :count) == 2
    end

    test "commit of [] is a no-op" do
      assert {:ok, %{imported: [], errors: []}} = CsvImport.commit([])
    end

    test "commit accepts map with :valid and :refinements and applies both" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, p1} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid)

      refined_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"

      {:ok, p2} = CsvImport.preview(refined_csv, @submitter)
      assert [_ref] = p2.refinements

      assert {:ok, %{imported: [], refined: [refined], errors: []}} =
               CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements})

      assert refined.id == existing.id
      assert refined.grid1 == "EM12KP37"
    end

    test "commit with grid refinement re-enqueues enrichment" do
      csv = @valid_header <> "\n" <> @valid_row
      {:ok, p1} = CsvImport.preview(csv, @submitter)
      {:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid)

      # Drive enrichment statuses to :complete so a reset is observable.
      Repo.update!(
        Ecto.Changeset.change(existing, %{
          hrrr_status: :complete,
          weather_status: :complete,
          terrain_status: :complete,
          iemre_status: :complete
        })
      )

      refined_csv =
        @valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"

      {:ok, p2} = CsvImport.preview(refined_csv, @submitter)

      assert {:ok, %{refined: [refined]}} =
               CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements})

      # After apply_contact_refinement + re-enqueue, HRRR enrichment ran
      # again and (because the HRRR stub returns 404) lands at :unavailable.
      # Weather/iemre/terrain may resolve back to :complete if no jobs could
      # be built, so HRRR is the definitive "was it re-enqueued" signal.
      reloaded = Repo.reload!(refined)
      refute reloaded.hrrr_status == :complete
    end

    test "commit with mode-only refinement does not re-enqueue enrichment" do
      {:ok, existing} =
        Microwaveprop.Radio.create_contact(%{
          station1: "W5XD",
          station2: "K5TR",
          grid1: "EM12",
          grid2: "EM00",
          band: "10000",
          qso_timestamp: ~U[2026-03-28 18:00:00Z],
          submitter_email: @submitter
        })

      Repo.update!(
        Ecto.Changeset.change(existing, %{
          hrrr_status: :complete,
          weather_status: :complete,
          terrain_status: :complete,
          iemre_status: :complete
        })
      )

      refined_csv = @valid_header <> "\n" <> @valid_row
      {:ok, p} = CsvImport.preview(refined_csv, @submitter)
      assert [%{changes: %{mode: "CW"}}] = p.refinements

      assert {:ok, %{refined: [refined]}} =
               CsvImport.commit(%{valid: p.valid, refinements: p.refinements})

      reloaded = Repo.reload!(refined)
      assert reloaded.mode == "CW"
      assert reloaded.hrrr_status == :complete
      assert reloaded.weather_status == :complete
      assert reloaded.terrain_status == :complete
      assert reloaded.iemre_status == :complete
    end

    test "commit(list) backward compat still works" do
      csv =
        Enum.join(
          [
            @valid_header,
            "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
          ],
          "\n"
        )

      {:ok, preview} = CsvImport.preview(csv, @submitter)
      assert {:ok, result} = CsvImport.commit(preview.valid)
      assert [_contact] = result.imported
      assert result.errors == []
      # The legacy list form still ships a :refined key with an empty list.
      assert result.refined == []
    end
  end

  describe "enqueue/2" do
    alias Microwaveprop.Radio.ImportRun
    alias Microwaveprop.Workers.ContactImportWorker

    # Uses testing: :inline — jobs run as they're inserted. We inspect
    # the post-run state (which exercises the worker too) and then
    # reset-and-re-count to verify the pre-run shape separately.
    test "creates an ImportRun with all rows serialized" do
      valid = [
        %{
          row_num: 2,
          attrs: %{
            "station1" => "W5XD",
            "station2" => "K5TR",
            "grid1" => "EM12",
            "grid2" => "EM00",
            "band" => "10000",
            "mode" => "CW",
            "qso_timestamp" => "2026-03-28T18:00:00Z",
            "submitter_email" => @submitter
          },
          timestamp: ~U[2026-03-28 18:00:00Z]
        }
      ]

      preview = %{valid: valid, refinements: []}

      assert {:ok, run_id} = CsvImport.enqueue(preview, @submitter)
      run = Repo.get!(ImportRun, run_id)
      assert run.submitter_email == @submitter
      assert run.total_rows == 1
      assert [%{"kind" => "insert", "row_num" => 2}] = run.rows["rows"]
      assert run.rows["refinement_ids"] == []
    end

    test "enqueues one ContactImportWorker job per chunk" do
      # 250 rows -> 3 jobs at chunk_size 100.
      valid =
        for i <- 0..249 do
          %{
            row_num: i + 2,
            attrs: %{
              "station1" => "W5XD",
              "station2" => "K5TR",
              "grid1" => "EM12",
              "grid2" => "EM00",
              "band" => "10000",
              "mode" => "CW",
              "qso_timestamp" => "2026-03-28T18:00:00Z",
              "submitter_email" => @submitter
            },
            timestamp: ~U[2026-03-28 18:00:00Z]
          }
        end

      preview = %{valid: valid, refinements: []}

      {:ok, _run_id} =
        Oban.Testing.with_testing_mode(:manual, fn ->
          CsvImport.enqueue(preview, @submitter)
        end)

      # Three chunks: [0, 100), [100, 200), [200, 250).
      assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 0, "limit" => 100})
      assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 100, "limit" => 100})
      assert_enqueued(worker: ContactImportWorker, args: %{"offset" => 200, "limit" => 50})
      assert length(all_enqueued(worker: ContactImportWorker)) == 3
    end

    test "returns pre-run counters and status on the persisted run" do
      # Fresh empty run so counters show their defaults regardless of
      # what the inline worker does.
      preview = %{valid: [], refinements: []}

      assert {:ok, run_id} = CsvImport.enqueue(preview, @submitter)
      run = Repo.get!(ImportRun, run_id)
      assert run.total_rows == 0
      assert run.processed_rows == 0
      assert run.imported_count == 0
      assert run.refined_count == 0
      assert run.error_count == 0
      assert run.errors == %{}
      # No rows, no jobs enqueued — status stays pending.
      assert run.status == "pending"
    end
  end
end
