defmodule Microwaveprop.Radio.CsvImport do
  @moduledoc """
  Parse a CSV of contacts into a preview (validated + de-duplicated) and then
  commit those rows into the database.

  Upload flow:

      {:ok, preview} = CsvImport.preview(csv_string, submitter_email)
      # show preview.valid / preview.invalid / preview.duplicates to the user
      {:ok, result} = CsvImport.commit(preview.valid)

  Two rows count as duplicates of each other if they share:

    * the same pair of callsigns (direction-agnostic),
    * the same grids for those callsigns,
    * the same band, and
    * timestamps within 60 minutes of each other.

  Dedup runs both against existing DB contacts and against earlier rows in
  the same CSV (first row wins, subsequent matches are flagged).
  """

  import Ecto.Query, only: [from: 2]

  alias Microwaveprop.Radio
  alias Microwaveprop.Radio.BandResolver
  alias Microwaveprop.Radio.Contact
  alias Microwaveprop.Radio.ImportMatcher
  alias Microwaveprop.Radio.ImportRun
  alias Microwaveprop.Repo
  alias Microwaveprop.Workers.ContactImportWorker
  alias Microwaveprop.Workers.ContactWeatherEnqueueWorker

  @dedup_window_seconds 3600
  @chunk_size 100

  # Header parsing is case-insensitive and whitespace-insensitive. Accepts
  # both the raw field names (station1, grid1, ...) and the human labels the
  # /contacts live_table export produces (Station 1, Grid 1, QSO (UTC), ...)
  # so export → import round-trips. Unknown columns are ignored.
  @header_aliases %{
    "station1" => "station1",
    "station 1" => "station1",
    "station2" => "station2",
    "station 2" => "station2",
    "grid1" => "grid1",
    "grid 1" => "grid1",
    "grid2" => "grid2",
    "grid 2" => "grid2",
    "band" => "band",
    "mode" => "mode",
    "qso_timestamp" => "qso_timestamp",
    "qso timestamp" => "qso_timestamp",
    "qso (utc)" => "qso_timestamp"
  }

  @required_columns ~w(station1 station2 grid1 grid2 band qso_timestamp)

  @doc """
  Parse-and-insert in one shot. Kept for backward compatibility — does NOT
  perform duplicate detection. New code should use `preview/2` + `commit/1`.
  """
  @type preview_result :: %{
          valid: [map()],
          invalid: [map()],
          duplicates: [map()],
          refinements: [map()],
          total_rows: non_neg_integer(),
          submitter_email: String.t()
        }

  @type import_result :: %{
          imported: [Contact.t()],
          errors: [{pos_integer(), [String.t()]}]
        }

  @type commit_result :: %{
          imported: [Contact.t()],
          refined: [Contact.t()],
          errors: [{pos_integer(), [String.t()]}]
        }

  @spec import(String.t(), String.t()) ::
          {:ok, import_result()}
          | {:error, :empty_csv}
          | {:error, :no_data_rows}
          | {:error, {:missing_required_columns, [String.t()]}}
  def import(csv_string, submitter_email) do
    with {:ok, preview} <- preview(csv_string, submitter_email) do
      {:ok, commit_result} = commit(%{valid: preview.valid, refinements: preview.refinements})
      invalid_errors = Enum.map(preview.invalid, fn %{row_num: n, messages: m} -> {n, m} end)

      {:ok,
       %{
         imported: commit_result.imported,
         errors: invalid_errors ++ commit_result.errors
       }}
    end
  end

  @doc """
  Parses the CSV, validates each row with the submission changeset, and
  classifies every row as valid / invalid / duplicate without inserting.

  Returns:

    * `{:ok, preview}` where `preview` is a map with keys
      `:valid`, `:invalid`, `:duplicates`, `:total_rows`, `:submitter_email`.
    * `{:error, :empty_csv}` if there is nothing to process.
    * `{:error, :no_data_rows}` if only a header is present.

  Valid rows are maps of the form `%{row_num, attrs, timestamp}`. Duplicate
  rows add a `:reason` field indicating whether the conflict was found in
  the database (`:existing_contact`) or earlier in the uploaded file
  (`:earlier_in_upload`).
  """
  @spec preview(String.t(), String.t()) ::
          {:ok, preview_result()}
          | {:error, :empty_csv}
          | {:error, :no_data_rows}
          | {:error, {:missing_required_columns, [String.t()]}}
  def preview(csv_string, submitter_email) do
    with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
      {valid, invalid} = split_parsed(rows)
      existing = load_existing_for_dedup(valid)
      {unique, duplicates, refinements} = dedupe(valid, existing)

      {:ok,
       %{
         valid: unique,
         invalid: invalid,
         duplicates: duplicates,
         refinements: refinements,
         total_rows: length(rows),
         submitter_email: submitter_email
       }}
    end
  end

  @doc """
  Commit a preview. Accepts either a list of valid rows (legacy) or a map with
  `:valid` and `:refinements` keys. Valid rows are inserted via
  `Radio.create_contact/1`. Refinements are applied to their matching existing
  contacts via `Radio.apply_contact_refinement/2`; grid-changing refinements
  re-enqueue enrichment, mode-only refinements do not.

  Returns `{:ok, %{imported: [...], refined: [...], errors: [{row_num, messages}, ...]}}`.
  """
  @spec commit([map()] | %{valid: [map()], refinements: [map()]}) :: {:ok, commit_result()}
  def commit(valid_rows) when is_list(valid_rows) do
    commit(%{valid: valid_rows, refinements: []})
  end

  def commit(%{valid: valid_rows, refinements: refinements}) do
    {:ok, commit_rows(%{valid: valid_rows, refinements: refinements})}
  end

  @doc """
  Synchronously run a batch of CSV rows through the insert / refinement
  pipeline without wrapping the result in an `{:ok, _}` tuple. This is
  the worker-facing variant of `commit/1` and is called per-chunk by
  `Microwaveprop.Workers.ContactImportWorker`.

  Accepts the same shape as `commit/1`: either a list of valid rows
  (legacy) or a `%{valid: [...], refinements: [...]}` map.
  """
  @spec commit_rows([map()] | %{valid: [map()], refinements: [map()]}) :: commit_result()
  def commit_rows(valid_rows) when is_list(valid_rows) do
    commit_rows(%{valid: valid_rows, refinements: []})
  end

  def commit_rows(%{valid: valid_rows, refinements: refinements}) do
    init = %{imported: [], refined: [], errors: []}

    result =
      init
      |> apply_valid_rows(valid_rows)
      |> apply_refinements(refinements)

    %{
      imported: Enum.reverse(result.imported),
      refined: Enum.reverse(result.refined),
      errors: Enum.reverse(result.errors)
    }
  end

  @doc """
  Persist the preview result as an `ImportRun` and enqueue one
  `ContactImportWorker` job per chunk of rows. Returns
  `{:ok, import_run_id}`. The caller can then subscribe to
  `"csv_import:<id>"` on `Microwaveprop.PubSub` to track progress.

  The submitted rows are stored once on the `ImportRun` row as a JSONB
  blob `%{"rows" => [...], "refinement_ids" => [...]}`. Valid-row entries
  come first and refinements are appended after; `refinement_ids` holds
  the `row_num` values of the refinements so the worker can route each
  row to the correct pipeline without re-classifying.
  """
  @spec enqueue(preview_result() | %{valid: [map()], refinements: [map()]}, String.t(), keyword()) ::
          {:ok, Ecto.UUID.t()}
  def enqueue(preview, submitter_email, opts \\ []) do
    private = Keyword.get(opts, :private, false)
    valid = preview |> Map.get(:valid, []) |> Enum.map(&stamp_private(&1, private))
    refinements = preview |> Map.get(:refinements, []) |> Enum.map(&stamp_private(&1, private))

    serialized_rows = Enum.map(valid, &serialize_valid_row/1) ++ Enum.map(refinements, &serialize_refinement_row/1)
    refinement_ids = Enum.map(refinements, & &1.row_num)
    total_rows = length(serialized_rows)

    attrs = %{
      submitter_email: submitter_email,
      rows: %{"rows" => serialized_rows, "refinement_ids" => refinement_ids},
      total_rows: total_rows
    }

    {:ok, run} =
      %ImportRun{}
      |> ImportRun.changeset(attrs)
      |> Repo.insert()

    enqueue_chunks(run.id, total_rows)

    {:ok, run.id}
  end

  defp enqueue_chunks(_run_id, 0), do: :ok

  defp enqueue_chunks(run_id, total_rows) do
    0
    |> Stream.iterate(&(&1 + @chunk_size))
    |> Stream.take_while(&(&1 < total_rows))
    |> Enum.each(fn offset ->
      limit = min(@chunk_size, total_rows - offset)

      %{"import_run_id" => run_id, "offset" => offset, "limit" => limit}
      |> ContactImportWorker.new()
      |> Oban.insert!()
    end)
  end

  defp stamp_private(%{attrs: attrs} = row, true), do: %{row | attrs: Map.put(attrs, "private", true)}
  defp stamp_private(row, _), do: row

  defp serialize_valid_row(row) do
    %{
      "kind" => "insert",
      "row_num" => row.row_num,
      "attrs" => row.attrs,
      "timestamp" => DateTime.to_iso8601(row.timestamp)
    }
  end

  defp serialize_refinement_row(row) do
    %{
      "kind" => "refinement",
      "row_num" => row.row_num,
      "attrs" => row.attrs,
      "timestamp" => DateTime.to_iso8601(row.timestamp),
      "existing_id" => row.existing_id,
      "changes" => stringify_changes(row.changes)
    }
  end

  # The refinement map comes out of ImportMatcher with atom keys
  # (:grid1/:grid2/:mode). JSONB serialization only round-trips string
  # keys so normalize here before handing the row to the worker.
  defp stringify_changes(changes) when is_map(changes) do
    Map.new(changes, fn {k, v} -> {to_string(k), v} end)
  end

  @doc """
  Decode a row out of the JSONB blob stored on `ImportRun.rows` back
  into the shape `commit_rows/1` expects.

  Returns `{:valid, row}` for insert rows and `{:refinement, row}` for
  refinements so the worker can route each row to the right pipeline.
  """
  @spec decode_row(map()) :: {:valid, map()} | {:refinement, map()}
  def decode_row(%{"kind" => "insert"} = row) do
    {:valid,
     %{
       row_num: row["row_num"],
       attrs: row["attrs"],
       timestamp: decode_timestamp(row["timestamp"])
     }}
  end

  def decode_row(%{"kind" => "refinement"} = row) do
    {:refinement,
     %{
       row_num: row["row_num"],
       attrs: row["attrs"],
       timestamp: decode_timestamp(row["timestamp"]),
       existing_id: row["existing_id"],
       changes: atomize_changes(row["changes"])
     }}
  end

  defp decode_timestamp(nil), do: nil

  defp decode_timestamp(iso) when is_binary(iso) do
    case DateTime.from_iso8601(iso) do
      {:ok, dt, _} -> dt
      _ -> nil
    end
  end

  @refinement_change_keys ~w(grid1 grid2 mode band station1 station2 qso_timestamp)
  defp atomize_changes(changes) when is_map(changes) do
    Map.new(changes, fn {k, v} ->
      key_str = to_string(k)

      if key_str in @refinement_change_keys do
        {String.to_existing_atom(key_str), v}
      else
        {key_str, v}
      end
    end)
  end

  defp atomize_changes(_), do: %{}

  @spec chunk_size() :: pos_integer()
  def chunk_size, do: @chunk_size

  defp apply_valid_rows(acc, valid_rows) do
    Enum.reduce(valid_rows, acc, fn row, acc ->
      case Radio.create_contact(row.attrs) do
        {:ok, contact} ->
          ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
          %{acc | imported: [contact | acc.imported]}

        {:error, :duplicate, _existing} ->
          acc

        {:error, %Ecto.Changeset{} = changeset} ->
          %{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]}
      end
    end)
  end

  defp apply_refinements(acc, refinements) do
    Enum.reduce(refinements, acc, &apply_refinement_row/2)
  end

  defp apply_refinement_row(ref, acc) do
    case Repo.get(Contact, ref.existing_id) do
      nil ->
        %{acc | errors: [{ref.row_num, ["existing contact no longer exists"]} | acc.errors]}

      %Contact{} = contact ->
        apply_refinement_to_contact(contact, ref, acc)
    end
  end

  defp apply_refinement_to_contact(contact, ref, acc) do
    case Radio.apply_contact_refinement(contact, ref.changes) do
      {:ok, refined} ->
        maybe_reenqueue_after_refinement(refined, ref.changes)
        %{acc | refined: [refined | acc.refined]}

      {:error, %Ecto.Changeset{} = changeset} ->
        %{acc | errors: [{ref.row_num, changeset_error_strings(changeset)} | acc.errors]}
    end
  end

  defp maybe_reenqueue_after_refinement(contact, changes) do
    if Map.has_key?(changes, :grid1) or Map.has_key?(changes, :grid2) do
      ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
    end

    :ok
  end

  # -- parsing ---------------------------------------------------------------

  defp parse_csv(csv_string, submitter_email) do
    numbered_lines =
      csv_string
      |> String.replace("\r\n", "\n")
      |> String.split("\n")
      |> Enum.with_index(1)

    non_blank = Enum.reject(numbered_lines, fn {line, _num} -> blank?(line) end)

    case non_blank do
      [] ->
        {:error, :empty_csv}

      [{_header, _}] ->
        {:error, :no_data_rows}

      [{header_line, _} | data_lines] ->
        with {:ok, column_map} <- parse_header(header_line) do
          {:ok, parse_data_rows(data_lines, submitter_email, column_map)}
        end
    end
  end

  defp parse_data_rows(data_lines, submitter_email, column_map) do
    Enum.map(data_lines, fn {line, row_num} ->
      parse_row(line, row_num, submitter_email, column_map)
    end)
  end

  defp blank?(line), do: String.trim(line) == ""

  # Builds an index → field-name map from the header line. Unknown headers
  # are silently skipped (that's how we accept the richer /contacts export).
  defp parse_header(header_line) do
    column_map =
      header_line
      |> parse_csv_fields()
      |> Enum.with_index()
      |> Enum.reduce(%{}, fn {raw, index}, acc ->
        case Map.fetch(@header_aliases, raw |> String.downcase() |> String.trim()) do
          {:ok, field} -> Map.put(acc, index, field)
          :error -> acc
        end
      end)

    present = column_map |> Map.values() |> MapSet.new()
    missing = Enum.reject(@required_columns, &MapSet.member?(present, &1))

    if missing == [] do
      {:ok, column_map}
    else
      {:error, {:missing_required_columns, missing}}
    end
  end

  defp parse_row(line, row_num, submitter_email, column_map) do
    fields = parse_csv_fields(line)

    attrs =
      column_map
      |> Enum.reduce(%{}, fn {index, field}, acc ->
        case Enum.at(fields, index) do
          nil -> acc
          value -> Map.put(acc, field, value)
        end
      end)
      |> Map.put("submitter_email", submitter_email)

    if required_present?(attrs) do
      parse_row_with_timestamp(attrs, row_num)
    else
      missing_on_row = Enum.reject(@required_columns, &Map.has_key?(attrs, &1))

      {:invalid,
       %{
         row_num: row_num,
         messages: ["row is missing required columns: #{Enum.join(missing_on_row, ", ")}"]
       }}
    end
  end

  defp required_present?(attrs), do: Enum.all?(@required_columns, &Map.has_key?(attrs, &1))

  defp parse_row_with_timestamp(attrs, row_num) do
    case normalize_timestamp(attrs["qso_timestamp"]) do
      {:ok, iso_timestamp} ->
        validate_parsed_row(attrs, row_num, iso_timestamp)

      {:error, msg} ->
        {:invalid, %{row_num: row_num, messages: [msg]}}
    end
  end

  defp validate_parsed_row(attrs, row_num, iso_timestamp) do
    attrs =
      attrs
      |> Map.put("qso_timestamp", iso_timestamp)
      |> normalize_band_attr()

    changeset = Contact.submission_changeset(%Contact{}, attrs)

    if changeset.valid? do
      {:ok, datetime} = parse_iso_datetime(iso_timestamp)
      {:parsed, %{row_num: row_num, attrs: attrs, timestamp: datetime}}
    else
      {:invalid, %{row_num: row_num, messages: changeset_error_strings(changeset)}}
    end
  end

  # CSV users may type ADIF wavelength labels ("33cm"), bare frequencies
  # ("903.100"), or canonical band numbers ("902"). Run the supplied value
  # through BandResolver so any of those forms end up as an integer MHz
  # string that Contact.submission_changeset's validate_inclusion accepts.
  # If the input is unresolvable we leave it alone so the user gets a
  # specific "band is invalid" changeset error instead of a silent drop.
  defp normalize_band_attr(attrs) do
    case attrs["band"] do
      nil ->
        attrs

      raw ->
        case BandResolver.resolve_as_string(raw) do
          nil -> attrs
          canonical -> Map.put(attrs, "band", canonical)
        end
    end
  end

  defp split_parsed(rows) do
    rows
    |> Enum.reduce({[], []}, fn
      {:parsed, row}, {valid, invalid} -> {[row | valid], invalid}
      {:invalid, row}, {valid, invalid} -> {valid, [row | invalid]}
    end)
    |> then(fn {valid, invalid} -> {Enum.reverse(valid), Enum.reverse(invalid)} end)
  end

  # -- dedup -----------------------------------------------------------------

  defp load_existing_for_dedup([]), do: %{}

  defp load_existing_for_dedup(valid_rows) do
    bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&to_decimal/1)
    timestamps = Enum.map(valid_rows, & &1.timestamp)
    min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second)
    max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second)

    from(c in Contact,
      where:
        c.band in ^bands and c.qso_timestamp >= ^min_ts and c.qso_timestamp <= ^max_ts and
          c.flagged_invalid == false
    )
    |> Repo.all()
    |> Enum.reduce(%{}, fn contact, acc ->
      case ImportMatcher.prefix_key(contact.station1, contact.grid1, contact.station2, contact.grid2, contact.band) do
        :invalid ->
          acc

        key ->
          Map.update(acc, key, [contact], &[contact | &1])
      end
    end)
  end

  defp dedupe(rows, db_map) do
    {accepted, _in_batch, duplicates, refinements} =
      Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map))

    {Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)}
  end

  defp classify_row(row, {accepted, in_batch, dups, refs}, db_map) do
    key = prefix_key_from_attrs(row.attrs)

    cond do
      key == :invalid ->
        {[row | accepted], in_batch, dups, refs}

      candidate = db_candidate(db_map, key, row.timestamp) ->
        classify_db_candidate(row, candidate, accepted, in_batch, dups, refs)

      batch_conflict?(in_batch, key, row.timestamp) ->
        {accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups], refs}

      true ->
        new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1])
        {[row | accepted], new_batch, dups, refs}
    end
  end

  defp classify_db_candidate(row, candidate, accepted, in_batch, dups, refs) do
    case ImportMatcher.classify_against_existing(row.attrs, candidate) do
      {:refinement, changes} ->
        refinement = %{
          row_num: row.row_num,
          attrs: row.attrs,
          timestamp: row.timestamp,
          existing_id: candidate.id,
          changes: changes
        }

        {accepted, in_batch, dups, [refinement | refs]}

      _ ->
        {accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups], refs}
    end
  end

  defp db_candidate(db_map, key, timestamp) do
    case Map.fetch(db_map, key) do
      :error -> nil
      {:ok, contacts} -> Enum.find(contacts, &within_window?(&1.qso_timestamp, timestamp))
    end
  end

  defp batch_conflict?(map, key, timestamp) do
    case Map.fetch(map, key) do
      :error -> false
      {:ok, timestamps} -> Enum.any?(timestamps, &within_window?(&1, timestamp))
    end
  end

  defp within_window?(t1, t2) do
    abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds
  end

  defp prefix_key_from_attrs(attrs) do
    ImportMatcher.prefix_key(
      attrs["station1"],
      attrs["grid1"],
      attrs["station2"],
      attrs["grid2"],
      attrs["band"]
    )
  end

  defp to_decimal(%Decimal{} = d), do: d
  defp to_decimal(v) when is_integer(v), do: Decimal.new(v)
  defp to_decimal(v) when is_binary(v), do: v |> String.trim() |> Decimal.new()

  defp parse_iso_datetime(iso) do
    case DateTime.from_iso8601(iso) do
      {:ok, dt, _} -> {:ok, dt}
      _ -> :error
    end
  end

  # -- timestamps ------------------------------------------------------------

  # Regex patterns for timestamp parsing, ordered most-specific first.
  # Accepts many hand-entered formats. All times assumed UTC.
  # Each returns {year, month, day, hour, minute, second} or nil.
  defp timestamp_parsers do
    [
      # ISO-ish: 2024-06-15T14:30:00Z, 2024-06-15 14:30, 2024/06/15 14:30:00, etc.
      # Accepts T or space separator, optional seconds, optional timezone suffix
      {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$"i,
       fn
         [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
         [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
       end},
      # Date only: 2024-06-15, 2024/06/15
      {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", fn [_, y, m, d] -> {y, m, d, "00", "00", "00"} end},
      # US with AM/PM: 6/15/2024 2:30 PM, 6-15-2024 2:30PM, 06.15.2024 02:30 am
      {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$"i,
       fn
         [_, m, d, y, h, mi, s, ampm] when byte_size(s) > 0 ->
           {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, s}

         [_, m, d, y, h, mi, _, ampm] ->
           {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, "00"}
       end},
      # US 24h: 6/15/2024 14:30, 06-15-2024 14:30:00, 6.15.2024 14:30
      {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$",
       fn
         [_, m, d, y, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
         [_, m, d, y, h, mi | _] -> {y, m, d, h, mi, "00"}
       end},
      # US date only: 6/15/2024, 06-15-2024
      {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$", fn [_, m, d, y] -> {y, m, d, "00", "00", "00"} end},
      # Compact: 20240615T143000, 20240615 1430, 20240615T1430
      {~r"^(\d{4})(\d{2})(\d{2})[T\s]?(\d{2})(\d{2})(\d{2})?$",
       fn
         [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
         [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
       end}
    ]
  end

  @doc """
  Parses various date/time formats into ISO 8601 UTC strings.
  All times are assumed UTC.
  """
  @spec normalize_timestamp(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def normalize_timestamp(raw) do
    trimmed = String.trim(raw)

    # Try ISO 8601 with Elixir's built-in parser first
    case DateTime.from_iso8601(trimmed) do
      {:ok, dt, _} ->
        {:ok, DateTime.to_iso8601(dt)}

      _ ->
        try_regex_parsers(trimmed)
    end
  end

  defp try_regex_parsers(trimmed) do
    timestamp_parsers()
    |> Enum.find_value(fn {regex, extractor} ->
      case Regex.run(regex, trimmed) do
        nil -> nil
        match -> format_iso(extractor.(match))
      end
    end)
    |> case do
      {:ok, _} = ok -> ok
      nil -> {:error, "qso_timestamp could not be parsed: #{trimmed}"}
    end
  end

  defp parse_12h(12, "AM"), do: 0
  defp parse_12h(12, "PM"), do: 12
  defp parse_12h(h, "PM"), do: h + 12
  defp parse_12h(h, "AM"), do: h

  defp format_iso({y, m, d, h, mi, s}) do
    y = String.pad_leading(to_string(y), 4, "0")
    m = String.pad_leading(to_string(m), 2, "0")
    d = String.pad_leading(to_string(d), 2, "0")
    h = String.pad_leading(to_string(h), 2, "0")
    mi = String.pad_leading(to_string(mi), 2, "0")
    s = String.pad_leading(to_string(s), 2, "0")
    {:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"}
  end

  # -- csv field parser ------------------------------------------------------

  # RFC 4180 CSV field parser. Handles quoted fields with commas and escaped quotes.
  defp parse_csv_fields(line) do
    line
    |> String.trim()
    |> do_parse_fields([], "")
    |> Enum.reverse()
    |> Enum.map(&String.trim/1)
  end

  defp do_parse_fields("", acc, current), do: [current | acc]

  defp do_parse_fields(<<"\"", rest::binary>>, acc, "") do
    parse_quoted_field(rest, acc, "")
  end

  defp do_parse_fields(<<",", rest::binary>>, acc, current) do
    do_parse_fields(rest, [current | acc], "")
  end

  defp do_parse_fields(<<c, rest::binary>>, acc, current) do
    do_parse_fields(rest, acc, current <> <<c>>)
  end

  defp parse_quoted_field(<<"\"\"", rest::binary>>, acc, current) do
    parse_quoted_field(rest, acc, current <> "\"")
  end

  defp parse_quoted_field(<<"\"", rest::binary>>, acc, current) do
    # End of quoted field — skip to next comma or end
    case rest do
      <<",", rest2::binary>> -> do_parse_fields(rest2, [current | acc], "")
      "" -> [current | acc]
      _ -> do_parse_fields(rest, [current | acc], "")
    end
  end

  defp parse_quoted_field(<<c, rest::binary>>, acc, current) do
    parse_quoted_field(rest, acc, current <> <<c>>)
  end

  defp parse_quoted_field("", acc, current), do: [current | acc]

  defp changeset_error_strings(changeset) do
    changeset
    |> Ecto.Changeset.traverse_errors(fn {message, opts} ->
      Regex.replace(~r"%{(\w+)}", message, fn _, key ->
        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
      end)
    end)
    |> Enum.flat_map(fn {field, messages} ->
      Enum.map(messages, &"#{field} #{&1}")
    end)
  end
end
