# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2021 The Elixir Team
# SPDX-FileCopyrightText: 2012 Plataformatec

defmodule ExUnit.CaptureServer do
  @moduledoc false
  @compile {:no_warn_undefined, Logger}
  @timeout :infinity
  @name __MODULE__
  @ets __MODULE__

  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, :ok, name: @name)
  end

  def device_capture_on(name, encoding, input) do
    GenServer.call(@name, {:device_capture_on, name, encoding, input}, @timeout)
  end

  def device_output(name, ref) do
    GenServer.call(@name, {:device_output, name, ref}, @timeout)
  end

  def device_capture_off(ref) do
    GenServer.call(@name, {:device_capture_off, ref}, @timeout)
  end

  def log_capture_on(pid, string_io, opts) do
    GenServer.call(@name, {:log_capture_on, pid, string_io, opts}, @timeout)
  end

  def log_capture_off(ref) do
    GenServer.call(@name, {:log_capture_off, ref}, @timeout)
  end

  ## Callbacks

  @impl true
  def init(:ok) do
    :ets.new(@name, [:named_table, :public, :set])

    state = %{
      devices: %{},
      log_captures: %{},
      log_status: :error
    }

    {:ok, state}
  end

  @impl true
  def handle_call(call, from, state)

  def handle_call({:device_capture_on, name, encoding, input}, {caller, _}, config) do
    capture_device(name, encoding, input, config, caller)
  end

  def handle_call({:device_output, name, ref}, _from, config) do
    device = Map.fetch!(config.devices, name)
    {_, output} = StringIO.contents(device.pid)
    total = byte_size(output)
    {_pid, offset} = Map.fetch!(device.refs, ref)
    output_size = total - offset
    {:reply, binary_part(output, offset, output_size), config}
  end

  def handle_call({:device_capture_off, ref}, _from, config) do
    {:reply, :ok, release_device(ref, config)}
  end

  def handle_call({:log_capture_on, pid, string_io, opts}, _from, config) do
    ref = Process.monitor(pid)
    refs = Map.put(config.log_captures, ref, true)

    {level, opts} = Keyword.pop(opts, :level)

    {formatter_mod, formatter_config} =
      Keyword.get_lazy(opts, :formatter, fn -> Logger.default_formatter(opts) end)

    true = :ets.insert(@ets, {ref, string_io, level || :all, formatter_mod, formatter_config})

    if map_size(refs) == 1 do
      :ok = :logger.add_handler(@name, __MODULE__, %{})

      status =
        with {:ok, config} <- :logger.get_handler_config(:default),
             :ok <- :logger.remove_handler(:default) do
          {:ok, config}
        else
          _ -> :error
        end

      {:reply, ref, %{config | log_captures: refs, log_status: status}}
    else
      {:reply, ref, %{config | log_captures: refs}}
    end
  end

  def handle_call({:log_capture_off, ref}, _from, config) do
    Process.demonitor(ref, [:flush])
    config = remove_log_capture(ref, config)
    {:reply, :ok, config}
  end

  @impl true
  def handle_info({:DOWN, ref, _, _, _}, config) do
    config = remove_log_capture(ref, config)
    config = release_device(ref, config)
    {:noreply, config}
  end

  defp capture_device(name, encoding, input, config, caller) do
    case config.devices do
      %{^name => device} ->
        dead_refs = for {ref, {pid, _}} <- device.refs, not Process.alive?(pid), do: ref

        case dead_refs do
          [] ->
            capture_existing_device(name, encoding, input, config, caller)

          _ ->
            config = Enum.reduce(dead_refs, config, &release_device/2)
            capture_device(name, encoding, input, config, caller)
        end

      %{} ->
        capture_new_device(name, encoding, input, config, caller)
    end
  end

  defp capture_existing_device(name, encoding, input, config, caller) do
    case Map.fetch!(config.devices, name) do
      %{input?: input?} when input? or input != "" ->
        {:reply, {:error, :input_on_already_captured_device}, config}

      %{encoding: ^encoding} = device ->
        {_, output} = StringIO.contents(device.pid)
        ref = Process.monitor(caller)
        config = put_in(config.devices[name].refs[ref], {caller, byte_size(output)})
        {:reply, {:ok, ref}, config}

      %{encoding: other_encoding} ->
        {:reply, {:error, {:changed_encoding, other_encoding}}, config}
    end
  end

  defp capture_new_device(name, encoding, input, config, caller) do
    {:ok, pid} = StringIO.open(input, encoding: encoding)
    original_pid = Process.whereis(name)

    try do
      Process.unregister(name)
      Process.register(pid, name)
    rescue
      ArgumentError ->
        {:reply, {:error, :no_device}, config}
    else
      _ ->
        ref = Process.monitor(caller)

        device = %{
          original_pid: original_pid,
          pid: pid,
          refs: %{ref => {caller, 0}},
          encoding: encoding,
          input?: input != ""
        }

        {:reply, {:ok, ref}, put_in(config.devices[name], device)}
    end
  end

  defp release_device(ref, %{devices: devices} = config) do
    Process.demonitor(ref, [:flush])

    case Enum.find(devices, fn {_, device} -> Map.has_key?(device.refs, ref) end) do
      {name, device} ->
        case Map.delete(device.refs, ref) do
          refs when map_size(refs) == 0 ->
            revert_device_to_original_pid(name, device.original_pid)
            close_string_io(device.pid)
            %{config | devices: Map.delete(devices, name)}

          refs ->
            put_in(config.devices[name].refs, refs)
        end

      _ ->
        config
    end
  end

  defp revert_device_to_original_pid(name, pid) do
    Process.unregister(name)
  rescue
    ArgumentError -> nil
  after
    Process.register(pid, name)
  end

  defp close_string_io(pid) do
    StringIO.close(pid)
  rescue
    ArgumentError -> nil
  end

  defp remove_log_capture(ref, %{log_captures: refs} = config) do
    true = :ets.delete(@ets, ref)

    case Map.pop(refs, ref, false) do
      {true, refs} ->
        maybe_revert_to_default_handler(refs, config.log_status)
        %{config | log_captures: refs}

      {false, _refs} ->
        config
    end
  end

  defp maybe_revert_to_default_handler(refs, status) when map_size(refs) == 0 do
    :logger.remove_handler(@name)

    with {:ok, %{module: module} = config} <- status do
      :logger.add_handler(:default, module, config)
    end
  end

  defp maybe_revert_to_default_handler(_refs, _config) do
    :ok
  end

  ## :logger handler callback.

  def log(event, _config) do
    {:trap_exit, trapping_exits?} = Process.info(self(), :trap_exit)

    tasks =
      :ets.tab2list(@ets)
      |> Enum.filter(fn {_ref, _string_io, level, _formatter_mod, _formatter_config} ->
        :logger.compare_levels(event.level, level) in [:gt, :eq]
      end)
      |> Enum.group_by(
        fn {_ref, _string_io, _level, formatter_mod, formatter_config} ->
          {formatter_mod, formatter_config}
        end,
        fn {_ref, string_io, _level, _formatter_mod, _formatter_config} ->
          string_io
        end
      )
      |> Enum.map(fn {{formatter_mod, formatter_config}, string_ios} ->
        Task.async(fn ->
          chardata = formatter_mod.format(event, formatter_config)

          # Simply send, do not wait for reply
          for string_io <- string_ios do
            send(string_io, {:io_request, self(), make_ref(), {:put_chars, :unicode, chardata}})
          end
        end)
      end)

    Task.await_many(tasks)

    if trapping_exits? do
      for %{pid: pid} <- tasks do
        receive do
          {:EXIT, ^pid, _} -> :ok
        end
      end
    end

    :ok
  end
end
