defmodule Microwaveprop.ObanErrorReporterTest do
  # async: false — test attaches/detaches a named :telemetry handler
  # and toggles per-test callback modules in the process dictionary.
  use ExUnit.Case, async: false

  import ExUnit.CaptureLog

  alias Microwaveprop.ObanErrorReporter

  # Worker stubs used to assert callback dispatch. Each records a message
  # back to the test process so we can `assert_receive`.

  defmodule PermanentWorker do
    @moduledoc false
    def on_permanent_failure(job) do
      send(:oban_error_reporter_test, {:permanent, job.id})
      :ok
    end
  end

  defmodule TransientWorker do
    @moduledoc false
    def on_transient_failure(job) do
      send(:oban_error_reporter_test, {:transient, job.id})
      :ok
    end
  end

  defmodule BothWorker do
    @moduledoc false
    def on_permanent_failure(job) do
      send(:oban_error_reporter_test, {:permanent, job.id})
      :ok
    end

    def on_transient_failure(job) do
      send(:oban_error_reporter_test, {:transient, job.id})
      :ok
    end
  end

  defmodule RaisingWorker do
    @moduledoc false
    def on_permanent_failure(_job), do: raise("callback boom")
    def on_transient_failure(_job), do: raise("callback boom")
  end

  defmodule SilentWorker do
    @moduledoc false
    # No callbacks — used to confirm the reporter still logs for workers
    # that haven't opted in.
  end

  setup do
    Process.register(self(), :oban_error_reporter_test)
    :ok = ObanErrorReporter.attach()

    on_exit(fn ->
      ObanErrorReporter.detach()
    end)

    :ok
  end

  defp execute_exception(metadata, measurements \\ %{duration: 123}) do
    :telemetry.execute([:oban, :job, :exception], measurements, metadata)
  end

  defp build_job(overrides) do
    defaults = %{
      id: 42,
      args: %{"foo" => "bar"},
      queue: "propagation",
      worker: "Microwaveprop.Workers.SomeWorker",
      attempt: 1,
      max_attempts: 3,
      state: "executing"
    }

    # Use a bare map (not Oban.Job struct) so tests don't depend on Oban
    # compile-time internals. The reporter only reads fields.
    Map.merge(defaults, overrides)
  end

  defp build_metadata(overrides) do
    job = build_job(Map.get(overrides, :job, %{}))

    Map.merge(
      %{
        job: job,
        worker: job.worker,
        queue: job.queue,
        kind: :error,
        reason: %RuntimeError{message: "kaboom"},
        stacktrace: [{Mod, :fun, 1, [file: ~c"x.ex", line: 1]}]
      },
      Map.delete(overrides, :job)
    )
  end

  describe "attach/0" do
    test "is idempotent — calling twice does not raise" do
      assert :ok = ObanErrorReporter.attach()
    end
  end

  describe "handle_event/4 on [:oban, :job, :exception]" do
    test "logs an error with structured metadata" do
      meta =
        build_metadata(%{
          job: %{
            id: 7,
            worker: "Microwaveprop.Workers.SilentWorker",
            queue: "weather",
            attempt: 1,
            max_attempts: 3
          }
        })

      log = capture_log(fn -> execute_exception(meta) end)

      assert log =~ "Oban job exception"
      assert log =~ "worker="
      assert log =~ "queue=weather"
      assert log =~ "attempt=1"
      assert log =~ "max_attempts=3"
      assert log =~ "retry_exhausted?=false"
      assert log =~ "kind=error"
      assert log =~ "RuntimeError"
      assert log =~ "job_id=7"
    end

    test "marks retry_exhausted? true when attempt >= max_attempts" do
      meta =
        build_metadata(%{
          job: %{
            id: 8,
            worker: to_string(SilentWorker),
            attempt: 3,
            max_attempts: 3
          }
        })

      log = capture_log(fn -> execute_exception(meta) end)
      assert log =~ "retry_exhausted?=true"
    end

    test "invokes on_permanent_failure/1 when retry exhausted and callback defined" do
      meta =
        build_metadata(%{
          job: %{
            id: 101,
            worker: to_string(PermanentWorker),
            attempt: 5,
            max_attempts: 5
          }
        })

      capture_log(fn -> execute_exception(meta) end)

      assert_receive {:permanent, 101}, 200
    end

    test "invokes on_transient_failure/1 on transient failure when callback defined" do
      meta =
        build_metadata(%{
          job: %{
            id: 202,
            worker: to_string(TransientWorker),
            attempt: 1,
            max_attempts: 5
          }
        })

      capture_log(fn -> execute_exception(meta) end)

      assert_receive {:transient, 202}, 200
    end

    test "dispatches only the matching callback when both are defined" do
      meta_transient =
        build_metadata(%{
          job: %{
            id: 303,
            worker: to_string(BothWorker),
            attempt: 1,
            max_attempts: 5
          }
        })

      capture_log(fn -> execute_exception(meta_transient) end)

      assert_receive {:transient, 303}, 200
      refute_receive {:permanent, 303}, 50

      meta_permanent =
        build_metadata(%{
          job: %{
            id: 404,
            worker: to_string(BothWorker),
            attempt: 5,
            max_attempts: 5
          }
        })

      capture_log(fn -> execute_exception(meta_permanent) end)

      assert_receive {:permanent, 404}, 200
      refute_receive {:transient, 404}, 50
    end

    test "a raising callback does not propagate, and the log still lands" do
      meta =
        build_metadata(%{
          job: %{
            id: 505,
            worker: to_string(RaisingWorker),
            attempt: 5,
            max_attempts: 5
          }
        })

      log =
        capture_log(fn ->
          # Must not raise.
          execute_exception(meta)
        end)

      assert log =~ "Oban job exception"
      assert log =~ "job_id=505"
      # Reporter surfaces the callback failure at warning level.
      assert log =~ "on_permanent_failure"
    end

    test "does not raise when worker module is unknown / not loaded" do
      meta =
        build_metadata(%{
          job: %{
            id: 606,
            worker: "Nonexistent.Worker.Module.ZZZ",
            attempt: 1,
            max_attempts: 3
          }
        })

      log = capture_log(fn -> execute_exception(meta) end)
      assert log =~ "job_id=606"
    end

    test "does not raise when reason is a plain term and stacktrace is missing" do
      meta = %{
        job: build_job(%{id: 707, worker: to_string(SilentWorker)}),
        worker: to_string(SilentWorker),
        queue: "propagation",
        kind: :exit,
        reason: :shutdown
      }

      log = capture_log(fn -> execute_exception(meta) end)
      assert log =~ "job_id=707"
      assert log =~ "kind=exit"
    end
  end
end
