if Phoenix.Sync.sandbox_enabled?() do
  defmodule Phoenix.Sync.Sandbox do
    @moduledoc """
    Integration between `Ecto.Adapters.SQL.Sandbox` and `Electric` that produces
    replication events from Ecto operations within a sandboxed connection.

    In normal operation `Electric` creates and consumes a logical replication
    slot on your Postgres database. This makes testing difficult because this
    replication stream is stateful and will not emit events when testing using
    `Ecto.Adapters.SQL.Sandbox` (which aborts all operations before they could
    appear in the replication stream).

    `Phoenix.Sync.Sandbox` uses a custom `Ecto.Adapter` that intercepts writes
    within a sandboxed connection and emits change events to a per-test
    replication stack.

    ## Integration

    ### Step 1

    In your `config/test.exs` file, set the `mode` to `:sandbox`:

        config :phoenix_sync,
          env: config_env(),
          mode: :sandbox

    ### Step 2

    Replace your `Ecto.Repo`'s adapter with our sandbox adapter macro:


        # before
        defmodule MyApp.Repo do
          use Ecto.Repo,
            otp_app: :my_app,
            adapter: Ecto.Adapters.Postgres
        end


        # after
        defmodule MyApp.Repo do
          use Phoenix.Sync.Sandbox.Postgres

          use Ecto.Repo,
            otp_app: :my_app,
            adapter: Phoenix.Sync.Sandbox.Postgres.adapter()
        end

    This macro will configure the repo with `Ecto.Adapters.Postgres` in `dev`
    and `prod` environments but enable intercepting db writes in tests.

    ### Step 3

    In your test file, after your `Ecto.Adapters.SQL.Sandbox.checkout(Repo)`
    setup call, start a sandbox stack for your repo:


        # before
        setup do
          :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
        end

        # after
        setup do
          :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
          # start our sandbox replication stack
          Phoenix.Sync.Sandbox.start!(Repo)
        end

    Or if you're using `Ecto.Adapters.SQL.Sandbox.start_owner!/2`:

        # before
        setup do
          pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo)
          on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
        end

        # after
        setup do
          pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo)
          on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
          # start our sandbox replication stack
          Phoenix.Sync.Sandbox.start!(Repo, pid)
        end

    Now in your tests, inserting via the configured repo will emit change
    messages for sandboxed writes, exactly as if you were reading from the
    Postgres replication stream.

    ## Collaborating processes

    ### Allowances

    The `Phoenix.Sync.Sandbox` uses the same ownership model as
    `Ecto.Adapters.SQL.Sandbox` -- and processes that automatically inherit
    access to the sandboxed connection will also register themselves to the
    test electric stack.

        test "tasks have access" do
          start_supervised!({Task, fn ->
            # this will succeed and broadcast a change event on the test's
            # sandbox replication stream
            Repo.insert!(%MyApp.Task{title: "Test Task"})
          end})
        end

    However processes started outside of the test process tree that need to be
    [explicitly granted access to the sandboxed
    connection](https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.Sandbox.html#module-allowances)
    will also need to be explicitly registered to the current test's
    replication stream using `#{inspect(__MODULE__)}.allow/3`. This calls
    `Ecto.Adapters.SQL.Sandbox.allow/3` and also registers the `allow` pid
    against the current process's replication stack.

    So where you would normally need to call
    `Ecto.Adapters.SQL.Sandbox.allow/3` simply call
    `#{inspect(__MODULE__)}.allow/3` instead:

        test "calls worker that runs a query" do
          allow = Process.whereis(MyApp.Worker)
          #{inspect(__MODULE__)}.allow(Repo, self(), allow)
          GenServer.call(MyApp.Worker, :run_query)
        end

    ### Shared mode

    If you're configuring your Ecto sandbox in shared mode, you also need to configure
    the `Phoenix.Sync.Sandbox` to use shared mode by passing `shared: true` to `Phoenix.Sync.Sandbox.start!/3`.

        # before
        setup(tags) do
          pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo, shared: not tags[:async])
          on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
        end

        # after
        setup(tags) do
          pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo, shared: not tags[:async])
          on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
          Phoenix.Sync.Sandbox.start!(Repo, pid, shared: not tags[:async])
        end

    ## Integrations

    ### Oban

    Because `Phoenix.Sync.Sandbox.Postgres.adapter/0` configures your
    `Ecto.Repo` to use a non-standard adapter module it causes problems
    with Oban's migration system.

    If you see an error like:

    ``` txt
    ** (KeyError) key :migrator not found in: [
      # your repo config...
    ]
    ```

    then the solution is to force Oban to use its Postgres migrator in your `config/test.exs`:

        config :my_app, MyApp.Repo,
          # base config...
          migrator: Oban.Migrations.Postgres

    ## Limitations

    The sandbox adapter will intercept the following functions:

    - `c:Ecto.Repo.delete_all/2`
    - `c:Ecto.Repo.update_all/3`
    - `c:Ecto.Repo.delete/2`
    - `c:Ecto.Repo.delete!/2`
    - `c:Ecto.Repo.insert/2`
    - `c:Ecto.Repo.insert!/2`
    - `c:Ecto.Repo.insert_all/3`
    - `c:Ecto.Repo.update/2`
    - `c:Ecto.Repo.update!/2`

    It does this by potentially re-writing the SQL for the query. Because of this
    there will be some more complex queries that will fail. Please [raise an
    issue](https://github.com/electric-sql/phoenix_sync/issues/new)
    if you hit problems.
    """

    @doc false
    use Supervisor

    alias __MODULE__
    alias __MODULE__.Error

    @type start_opts() :: [{:shared, boolean()}]

    @registry __MODULE__.Registry

    @doc false
    def start_link(_args \\ []) do
      Supervisor.start_link(__MODULE__, [], name: __MODULE__)
    end

    @doc """
    See `start!/3`.
    """
    @spec start!(Ecto.Repo.t()) :: :ok | no_return()
    def start!(repo) when is_atom(repo) do
      start!(repo, self(), [])
    end

    @doc """
    See `start!/3`.
    """
    @spec start!(Ecto.Repo.t(), pid() | start_opts()) :: :ok | no_return()
    def start!(repo, opts_or_owner)

    def start!(repo, opts) when is_atom(repo) and is_list(opts) do
      start!(repo, self(), opts)
    end

    def start!(repo, owner) when is_atom(repo) and is_pid(owner) do
      start!(repo, owner, [])
    end

    @doc """
    Start a sandbox instance for `repo` linked to the given `owner` process.

    Call this after your `Ecto.Adapters.SQL.Sandbox.start_owner!/2` or
    `Ecto.Adapters.SQL.Sandbox.checkout/2` call.

        setup do
          :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
          Phoenix.Sync.Sandbox.start!(Repo)
        end

    ### Options

    - `shared` (default: `false`) - start the sandbox in shared mode. Only
      enable shared mode if your repo sandbox is also in shared mode.
    - `tags` - pass the test tags in order to generate a stack identifier based
      on the current test context
    """
    @spec start!(Ecto.Repo.t(), pid(), start_opts()) :: :ok | no_return()
    def start!(repo, owner, opts) when is_atom(repo) and is_pid(owner) and is_list(opts) do
      test_pid = self()
      validate_sandbox_repo!(repo)

      case stack_id() do
        nil ->
          stack_id = generate_stack_id(opts)

          {:ok, _pid} =
            ExUnit.Callbacks.start_supervised(
              {__MODULE__.Stack, stack_id: stack_id, repo: repo, owner: owner}
            )

          :ok = maybe_set_shared_mode(owner, stack_id, opts)

          # give the inspector access to the sandboxed connection
          Ecto.Adapters.SQL.Sandbox.allow(repo, owner, Sandbox.Inspector.name(stack_id))

          # mark the stack as ready
          Electric.StatusMonitor.mark_pg_lock_acquired(stack_id, owner)
          Electric.StatusMonitor.mark_replication_client_ready(stack_id, owner)
          Electric.StatusMonitor.mark_connection_pool_ready(stack_id, owner)

          api_config = Sandbox.Stack.config(stack_id, repo)
          api = Electric.Application.api(api_config)

          {:ok, client} =
            Phoenix.Sync.Electric.client(:test, Keyword.put(api_config, :mode, :embedded))

          client = Map.put(client, :pool, {Phoenix.Sync.Sandbox.Fetch, stack_id: stack_id})

          # we link the sandbox to the current (test) process not the connection
          # owner because that's the ownership route that works. The owner
          # is a convenience to link the repo connection to a process who's lifetime
          # is explicitly managed rather than a mechanism for linking the test
          # to the sandbox txn.
          Sandbox.StackRegistry.register(test_pid, stack_id)

          # register the electric api and configured client to the stack
          Sandbox.StackRegistry.configure(stack_id, api, client)

          :ok

        stack_id ->
          case GenServer.whereis(__MODULE__.Stack.name(stack_id)) do
            nil -> raise Error, message: "no stack found for #{inspect(stack_id)}"
            _pid -> :ok
          end
      end
    end

    defp generate_stack_id(opts) do
      tags = Keyword.get(opts, :tags, %{})
      # with parameterised tests the same file:line can be running simultaneously
      uid = System.unique_integer() |> to_string()

      suffix =
        case Map.fetch(tags, :line) do
          {:ok, line} -> ":#{line}"
          :error -> ""
        end

      prefix =
        case Map.fetch(tags, :file) do
          {:ok, file} -> "-" <> Path.relative_to(file, Path.dirname(Mix.Project.project_file()))
          :error -> ""
        end

      "#{inspect(__MODULE__.Stack)}#{uid}#{prefix}#{suffix}"
    end

    defp maybe_set_shared_mode(owner, stack_id, opts) do
      if opts[:shared] do
        Sandbox.StackRegistry.shared_mode(owner, stack_id)
      else
        :ok
      end
    end

    @doc false
    def name(id) do
      {:via, Registry, {@registry, id}}
    end

    @doc false
    def retrieve_api!() do
      stack_id = stack_id!()
      {:ok, api} = Sandbox.StackRegistry.get_api(stack_id)
      api
    end

    @doc false
    def stack_id! do
      stack_id() ||
        raise Error, message: "No stack_id found. Did you call Phoenix.Sync.Sandbox.start!/1?"
    end

    @doc false
    def stack_id() do
      [self() | Process.get(:"$callers") || []]
      |> lookup_stack_id()
    end

    defp lookup_stack_id(pids) when is_list(pids) do
      # things can be inserted into a repo before the Phoenix.Sync application
      # has even started
      case GenServer.whereis(Sandbox.StackRegistry) do
        nil ->
          raise Error,
            message: """
            Phoenix.Sync.Sandbox is not running. Have you set the mode to `:sandbox` in `config/test.exs`?

            # config/test.exs
            config :phoenix_sync,
              env: config_env(),
              mode: :sandbox
            """

        registry_pid when is_pid(registry_pid) ->
          pids
          |> Stream.map(fn pid ->
            Sandbox.StackRegistry.lookup(registry_pid, pid)
          end)
          |> Enum.find(&(!is_nil(&1)))
      end
    end

    @doc """
    Allows the process `name_or_pid` to access the sandboxed transaction.

    This is a wrapper around `Ecto.Adapters.SQL.Sandbox.allow/3` that also
    connects the given process to the active sync instance.

    You should use it instead of `Ecto.Adapters.SQL.Sandbox.allow/3` for tests
    that are using the sandbox replication stack.

    `opts` is passed to `Ecto.Adapters.SQL.Sandbox.allow/3`.
    """
    @spec allow(Ecto.Repo.t(), pid(), pid() | GenServer.name(), keyword()) :: :ok | no_return()
    def allow(repo, parent, name_or_pid, opts \\ []) do
      with :ok <- Ecto.Adapters.SQL.Sandbox.allow(repo, parent, name_or_pid, opts) do
        [parent]
        |> lookup_stack_id()
        |> case do
          nil ->
            raise Error,
              message:
                "No stack_id found for process #{inspect(parent)}. Did you call Phoenix.Sync.Sandbox.start!/1?"

          stack_id ->
            case GenServer.whereis(name_or_pid) do
              pid when is_pid(pid) ->
                Sandbox.StackRegistry.register(pid, stack_id)

              other ->
                raise Error,
                  message:
                    "`allow/4` expects a PID or a locally registered process name but lookup returned: #{inspect(other)}"
            end
        end
      end
    end

    @doc false
    @spec init_test_session(Plug.Conn.t(), Ecto.Repo.t(), map()) :: Plug.Conn.t()
    def init_test_session(conn, _repo, session \\ %{}) do
      client = client!()

      conn
      |> Plug.Test.init_test_session(session)
      |> Plug.Conn.put_private(:electric_client, client)
    end

    @doc false
    def plug_opts do
      Phoenix.Sync.Application.plug_opts(mode: :sandbox)
    end

    @doc """
    Retrieve a client configured for the current sandbox stack.

    Example:

        {:ok, client} = Phoenix.Sync.Sandbox.client()
        Electric.Client.stream(client, Todo, replica: :full),

    """
    @spec client() :: {:ok, Electric.Client.t()} | {:error, String.t()}
    def client do
      if stack_id = stack_id() do
        Sandbox.StackRegistry.get_client(stack_id)
      else
        raise Error, message: "No stack_id found. Did you call Phoenix.Sync.Sandbox.start!/1?"
      end
    end

    @doc """
    As per `client/0` but raises if there is no stack configured for the
    current process.
    """
    @spec client!() :: Electric.Client.t() | no_return()
    def client!() do
      {:ok, client} = Sandbox.StackRegistry.get_client(stack_id!())

      client
    end

    @doc false
    def must_refetch!(shape) do
      stack_id = stack_id!()

      {%{namespace: namespace, table: table}, _} =
        shape
        |> Phoenix.Sync.PredefinedShape.new!()
        |> Phoenix.Sync.PredefinedShape.to_stream_params()

      Sandbox.Producer.truncate(stack_id, {namespace || "public", table})
    end

    @impl true
    @doc false
    def init(_) do
      children = [
        {Registry, keys: :unique, name: @registry},
        Sandbox.StackRegistry
      ]

      Supervisor.init(children, strategy: :one_for_one)
    end

    defp validate_sandbox_repo!(repo) do
      if Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0) do
        if repo.__adapter__() != Sandbox.Postgres.Adapter do
          raise RuntimeError,
                "Repo #{inspect(repo)} is not using the Phoenix.Sync.Sandbox.Postgres adapter. Please ensure you have configured your repo to use the sandbox adapter."
        end
      end
    end
  end
end
