defmodule Owl.LiveScreen do
  @moduledoc ~S"""
  A server that handles live updates in the terminal.

  It partially implements [The Erlang I/O Protocol](https://www.erlang.org/doc/apps/stdlib/io_protocol.html),
  so it is possible to use `Owl.LiveScreen` as an I/O device in `Logger`
  and functions like `Owl.IO.puts/2`, `IO.puts/2`. When used as an I/O device, output is printed above dynamic blocks.

  ## Example

      require Logger
      :ok = :logger.remove_handler(:default)

      :ok =
        :logger.add_handler(:default, :logger_std_h, %{
          config: %{type: {:device, Owl.LiveScreen}},
          formatter: Logger.Formatter.new()
        })

      Owl.LiveScreen.add_block(:dependency,
        state: :init,
        render: fn
          :init -> "init..."
          dependency -> ["dependency: ", Owl.Data.tag(dependency, :yellow)]
        end
      )

      Owl.LiveScreen.add_block(:compiling,
        render: fn
          :init -> "init..."
          filename -> ["compiling: ", Owl.Data.tag(to_string(filename), :cyan)]
        end
      )

      ["ecto", "phoenix", "ex_doc", "broadway"]
      |> Enum.each(fn dependency ->
        Owl.LiveScreen.update(:dependency, dependency)

        1..5
        |> Enum.map(&"filename#{&1}.ex")
        |> Enum.each(fn filename ->
          Owl.LiveScreen.update(:compiling, filename)
          Process.sleep(1000)
          Logger.debug("#{filename} compiled for dependency #{dependency}")
        end)
      end)
  """
  use GenServer

  @type block_id :: any()
  @type add_block_option :: {:state, any()} | {:render, (block_state :: any() -> Owl.Data.t())}
  @type start_option ::
          {:name, GenServer.name()}
          | {:refresh_every, pos_integer()}
          | {:terminal_width, pos_integer() | :auto}
          | {:device, IO.device()}

  @refresh_every_default 60

  @doc """
  Starts a server.

  Server is started automatically by `:owl` application as a named process.

  ## Options

  * `:name` - used for name registration as described in the "Name
  registration" section in the documentation for `GenServer`. Defaults to `Owl.LiveScreen`.
  * `:refresh_every` - the interval for refreshing the screen, in milliseconds. Defaults to #{@refresh_every_default}.
  * `:terminal_width` - the terminal width in characters. Defaults to `:auto`, which uses `Owl.IO.columns/1`.
  * `:device` - an I/O device. Defaults to `:stdio`.
  If a terminal is not available, the server will not be started.
  """
  @spec start_link([start_option()]) :: GenServer.on_start()
  def start_link(opts) do
    server_options = Keyword.take(opts, [:name])

    GenServer.start_link(__MODULE__, opts, server_options)
  end

  @doc """
  Updates the device used by LiveScreen.

  This is useful, for example, when using the Erlang SSH console.

  ## Example
      group_leader = Process.group_leader()

      Owl.LiveScreen.set_device(group_leader)
  """
  @spec set_device(GenServer.server(), IO.device()) :: :ok
  def set_device(server \\ __MODULE__, new_device) do
    GenServer.call(server, {:set_device, new_device})
  end

  @doc """
  Returns the current device used by the LiveScreen.
  """
  @spec get_device(GenServer.server()) :: IO.device()
  def get_device(server \\ __MODULE__) do
    GenServer.call(server, :get_device)
  end

  @doc """
  Redirects output from `:stdio` to `#{inspect(__MODULE__)}`.

  ## Example

      Owl.ProgressBar.start(id: :users, label: "Creating users", total: 100)

      Owl.LiveScreen.capture_stdio(fn ->
        Enum.each(1..100, fn i ->
          Process.sleep(10)
          Owl.ProgressBar.inc(id: :users)
          # Output from the next call will be printed above the progress bar
          IO.inspect([:test, i])
        end)
      end)

      Owl.LiveScreen.await_render()

  """
  @spec capture_stdio(GenServer.server(), (-> result)) :: result when result: any
  def capture_stdio(server \\ __MODULE__, callback) when is_function(callback, 0) do
    original_gl = Process.group_leader()

    live_screen_server_pid =
      case GenServer.whereis(server) do
        pid when is_pid(pid) -> pid
      end

    Process.group_leader(self(), live_screen_server_pid)

    try do
      Process.group_leader(self(), live_screen_server_pid)
      callback.()
    after
      Process.group_leader(self(), original_gl)
    end
  end

  @doc """
  Adds a sticky block to the bottom of the screen that can be updated using `update/3`.

  ## Options

  * `:render` - a function that accepts `state` and returns a view of the block. Defaults to `Function.identity/1`, which
  means `state` must have type `t:Owl.Data.t/0`.
  * `:state` - initial state of the block. Defaults to `nil`.

  ## Example

      Owl.LiveScreen.add_block(:footer, state: "starting...")
      # which is equivalent to
      Owl.LiveScreen.add_block(:footer, render: fn
        nil -> "starting..."
        data -> data
      end)
  """
  @spec add_block(GenServer.server(), block_id(), [add_block_option()]) :: :ok
  def add_block(server \\ __MODULE__, block_id, opts) when is_list(opts) do
    GenServer.cast(server, {:add_block, block_id, opts})
  end

  @doc """
  Updates the block state to be used in the next render iteration.

  ## Example

      Owl.LiveScreen.add_block(:footer, state: "starting...")
      Process.sleep(1000)
      Owl.LiveScreen.update(:footer, "...almost done...")
      Process.sleep(1000)
      Owl.LiveScreen.update(:footer, "done!!!")

  """
  @spec update(GenServer.server(), block_id(), block_state :: any()) :: :ok
  def update(server \\ __MODULE__, block_id, block_state) do
    GenServer.cast(server, {:update, block_id, block_state})
  end

  @doc """
  Waits for the next rendering.

  Useful to ensure that the last published state is rendered on the screen.

  ## Example

      Owl.LiveScreen.add_block(:status, state: "starting...")
      Owl.LiveScreen.update(:status, "done!")
      Owl.LiveScreen.await_render()
  """
  @spec await_render(GenServer.server()) :: :ok
  def await_render(server \\ __MODULE__) do
    GenServer.cast(server, {:notify_on_next_render, self()})

    receive do
      :rendered -> :ok
    end
  end

  @doc """
  Renders data in buffer and detaches blocks.
  """
  @spec flush(GenServer.server()) :: :ok
  def flush(server \\ __MODULE__) do
    GenServer.call(server, :flush)
  end

  @doc """
  Renders data in buffer and terminates a server.
  """
  @spec stop(GenServer.server()) :: :ok
  def stop(server \\ __MODULE__) do
    GenServer.stop(server)
  end

  # we define child_spec just to disable doc
  @doc false
  def child_spec(init_arg) do
    super(init_arg)
  end

  @impl true
  def init(opts) do
    refresh_every = opts[:refresh_every] || @refresh_every_default

    terminal_width = opts[:terminal_width] || :auto
    device = opts[:device] || :stdio

    terminal_device? = not is_nil(get_terminal_width(terminal_width, device))

    if terminal_device? do
      {:ok, init_state(terminal_width, refresh_every, device)}
    else
      :ignore
    end
  end

  defp init_state(terminal_width, refresh_every, device) do
    %{
      device: device,
      timer_ref: nil,
      notify_on_next_render: [],
      terminal_width: terminal_width,
      refresh_every: refresh_every,
      put_above_blocks: [],
      put_above_blocks_performed?: false,
      put_above_blocks_sources: [],
      content: %{},
      block_states: %{},
      render_functions: %{},
      rendered_blocks: [],
      rendered_content_height: %{},
      blocks_to_add: []
    }
  end

  @impl true
  def terminate(_, state) do
    render(state)
  end

  @impl true
  def handle_cast({:add_block, block_id, opts}, state) do
    block_state = opts[:state]
    render = opts[:render] || (&Function.identity/1)

    # initiate rendering when adding first block
    timer_ref =
      if is_nil(state.timer_ref) and empty_blocks_list?(state) do
        Process.send_after(self(), :render, state.refresh_every)
      else
        state.timer_ref
      end

    {:noreply,
     %{
       state
       | blocks_to_add: state.blocks_to_add ++ [block_id],
         block_states: Map.put(state.block_states, block_id, block_state),
         render_functions: Map.put(state.render_functions, block_id, render),
         timer_ref: timer_ref
     }}
  end

  def handle_cast({:notify_on_next_render, pid}, state) do
    empty_buffer? =
      state.put_above_blocks == [] and is_nil(state.timer_ref) and empty_blocks_list?(state)

    if empty_buffer? do
      send(pid, :rendered)
      {:noreply, state}
    else
      {:noreply, %{state | notify_on_next_render: [pid | state.notify_on_next_render]}}
    end
  end

  def handle_cast({:update, block_id, block_state}, state) do
    {:noreply,
     %{
       state
       | block_states: Map.put(state.block_states, block_id, block_state)
     }}
  end

  @impl true
  def handle_call({:set_device, new_device}, _, state) do
    state = render(state)

    state = init_state(state.terminal_width, state.refresh_every, new_device)

    {:reply, :ok, state}
  end

  def handle_call(:get_device, _, state) do
    {:reply, state.device, state}
  end

  def handle_call(:flush, _, state) do
    state = render(state)

    state = init_state(state.terminal_width, state.refresh_every, state.device)

    {:reply, :ok, state}
  end

  # for private usage without public interface
  def handle_call(:render, _, state) do
    state = render(state)
    {:reply, :ok, state}
  end

  @impl true
  def handle_info(:render, state) do
    state = render(state)

    timer_ref =
      if not is_nil(state.timer_ref) and not empty_blocks_list?(state) do
        case Process.read_timer(state.timer_ref) do
          false ->
            :noop

          _ms ->
            Process.cancel_timer(state.timer_ref)
        end

        Process.send_after(self(), :render, state.refresh_every)
      end

    {:noreply, %{state | timer_ref: timer_ref}}
  end

  def handle_info({:io_request, from, reply_as, req}, state) do
    state = io_request(from, reply_as, req, state)
    {:noreply, state}
  end

  def handle_info(_message, state) do
    {:noreply, state}
  end

  defp io_request(from, reply_as, {:put_chars, chars}, state) do
    put_chars(from, reply_as, chars, state)
  end

  defp io_request(from, reply_as, {:put_chars, mod, fun, args}, state) do
    put_chars(from, reply_as, apply(mod, fun, args), state)
  end

  defp io_request(from, reply_as, {:put_chars, _encoding, chars}, state) do
    put_chars(from, reply_as, chars, state)
  end

  defp io_request(from, reply_as, {:put_chars, _encoding, mod, fun, args}, state) do
    put_chars(from, reply_as, apply(mod, fun, args), state)
  end

  defp io_request(from, reply_as, req, state) do
    {reply, state} = io_request(req, state)
    io_reply(from, reply_as, reply)
    state
  end

  defp io_request({:get_chars, _prompt, _count}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_chars, _encoding, _prompt, _count}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_line, _prompt}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_line, _encoding, _prompt}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_until, _prompt, _mod, _fun, _args}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_until, _encoding, _prompt, _mod, _fun, _args}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_password, _encoding}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:setopts, _opts}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request(:getopts, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request({:get_geometry, :columns}, state) do
    {Owl.IO.columns(state.device) || {:error, :enotsup}, state}
  end

  defp io_request({:get_geometry, :rows}, state) do
    {Owl.IO.rows(state.device) || {:error, :enotsup}, state}
  end

  defp io_request({:requests, _reqs}, state) do
    {{:error, :enotsup}, state}
  end

  defp io_request(_, state) do
    {{:error, :request}, state}
  end

  defp put_chars(from, reply_as, chars, state) do
    send(self(), :render)

    %{
      state
      | put_above_blocks: [chars | state.put_above_blocks],
        put_above_blocks_sources: [{from, reply_as} | state.put_above_blocks_sources]
    }
  end

  defp io_reply(from, reply_as, reply) do
    send(from, {:io_reply, reply_as, reply})
  end

  defp get_terminal_width(:auto, device), do: Owl.IO.columns(device)
  defp get_terminal_width(number, _device) when is_integer(number), do: number

  defp render(state) do
    terminal_width = get_terminal_width(state.terminal_width, state.device)

    {state, render_above_data, io_reply} = render_above(state)

    # -1, because we need to leave one row for the last new line (cursor)
    rows_left = Owl.IO.rows(state.device) - 1

    {state, render_added_blocks_data, rows_left} =
      render_added_blocks(state, terminal_width, rows_left)

    {state, render_updated_blocks_data} =
      rerender_updated_blocks(state, render_above_data != [], terminal_width, rows_left)

    state = %{
      state
      | blocks_to_add: [],
        rendered_blocks: state.rendered_blocks ++ state.blocks_to_add
    }

    data =
      [
        render_above_data,
        render_updated_blocks_data,
        render_added_blocks_data
      ]
      |> Enum.reject(&(&1 == []))
      |> Owl.Data.unlines()

    if data != [] do
      Owl.IO.puts(data, state.device)
      io_reply.()
    end

    for pid <- state.notify_on_next_render do
      send(pid, :rendered)
    end

    %{state | block_states: %{}, notify_on_next_render: []}
  end

  defp get_content(state, block_id, terminal_width, rows_left) do
    case Map.fetch(state.block_states, block_id) do
      {:ok, block_state} ->
        block_content = state.render_functions[block_id].(block_state)

        lines =
          block_content
          |> Owl.Data.lines()
          |> Enum.flat_map(fn
            [] -> [[]]
            line -> Owl.Data.chunk_every(line, terminal_width)
          end)

        block_height = length(lines)

        max_height = max(block_height, state.rendered_content_height[block_id][:max] || 0)
        lines = lines ++ List.duplicate([], max_height - block_height)
        block_height = max_height
        new_rows_left = max(rows_left - block_height, 0)

        content_height_to_render = rows_left - new_rows_left

        lines_to_render =
          lines
          |> Enum.reverse()
          |> Enum.slice(0, content_height_to_render)
          |> Enum.reverse()

        block_content =
          lines
          |> Enum.map(&[IO.ANSI.clear_line(), &1])
          |> Owl.Data.unlines()

        content_to_render =
          if content_height_to_render == block_height do
            block_content
          else
            lines_to_render
            |> Enum.map(&[IO.ANSI.clear_line(), &1])
            |> Owl.Data.unlines()
          end

        %{
          rendered_new_state?: true,
          block_content: block_content,
          block_height: block_height,
          new_rows_left: new_rows_left,
          content_to_render: content_to_render,
          content_to_render_height: content_height_to_render
        }

      :error ->
        block_content = state.content[block_id]
        block_height = state.rendered_content_height[block_id].max

        new_rows_left = max(rows_left - block_height, 0)
        content_height_to_render = rows_left - new_rows_left

        content_to_render =
          if content_height_to_render == block_height do
            block_content
          else
            block_content
            |> Owl.Data.lines()
            |> Enum.reverse()
            |> Enum.slice(0, content_height_to_render)
            |> Enum.reverse()
            |> Owl.Data.unlines()
          end

        %{
          rendered_new_state?: false,
          block_content: block_content,
          block_height: block_height,
          new_rows_left: new_rows_left,
          content_to_render: content_to_render,
          content_to_render_height: content_height_to_render
        }
    end
  end

  defp noop, do: :noop

  defp render_above(%{put_above_blocks: []} = state), do: {state, [], &noop/0}

  defp render_above(%{put_above_blocks: put_above_blocks} = state) do
    blocks_height = Enum.sum(Enum.map(Map.values(state.rendered_content_height), & &1.rendered))
    data = Enum.reverse(put_above_blocks)

    cursor_up =
      if state.put_above_blocks_performed? do
        blocks_height + 1
      else
        blocks_height
      end

    data =
      if cursor_up == 0 do
        data
      else
        [cursor_up_and_clear_lines(cursor_up), data]
      end

    {%{
       state
       | put_above_blocks: [],
         put_above_blocks_performed?: true,
         put_above_blocks_sources: []
     }, data,
     fn ->
       state.put_above_blocks_sources
       |> Enum.reverse()
       |> Enum.each(fn {from, reply_as} ->
         io_reply(from, reply_as, :ok)
       end)
     end}
  end

  defp cursor_up_and_clear_lines(n) do
    List.duplicate([IO.ANSI.cursor_up(1), IO.ANSI.clear_line()], n)
  end

  defp rerender_updated_blocks(state, rendered_above?, terminal_width, rows_left) do
    blocks_to_replace = Map.keys(state.block_states) -- state.blocks_to_add

    if not rendered_above? and Enum.empty?(blocks_to_replace) do
      {state, []}
    else
      {content_blocks, %{state: state}} =
        state.rendered_blocks
        |> Enum.reverse()
        |> Enum.map_reduce(
          %{state: state, rows_left: rows_left},
          fn block_id, %{state: state, rows_left: rows_left} ->
            %{
              rendered_new_state?: rendered_new_state?,
              block_content: block_content,
              block_height: block_height,
              new_rows_left: new_rows_left,
              content_to_render: content_to_render,
              content_to_render_height: content_to_render_height
            } = get_content(state, block_id, terminal_width, rows_left)

            {%{
               height_diff:
                 content_to_render_height - state.rendered_content_height[block_id].rendered,
               content: content_to_render,
               height: content_to_render_height,
               rendered_new_state?: rendered_new_state?,
               visible?: rows_left > 0
             },
             %{
               rows_left: new_rows_left,
               state: %{
                 state
                 | rendered_content_height:
                     Map.put(state.rendered_content_height, block_id, %{
                       rendered: content_to_render_height,
                       max: block_height
                     }),
                   content: Map.put(state.content, block_id, block_content)
               }
             }}
          end
        )

      content_blocks = Enum.reverse(content_blocks)

      {content_blocks, %{total_height: total_height, next_offset: return_to_end}} =
        Enum.flat_map_reduce(
          content_blocks,
          %{
            content_above_increased_height?: rendered_above?,
            next_offset: 0,
            total_height: 0
          },
          fn block, acc ->
            cond do
              not block.visible? ->
                {[], acc}

              acc.content_above_increased_height? || block.height_diff > 0 ->
                {[Map.put(block, :offset, acc.next_offset)],
                 %{
                   acc
                   | content_above_increased_height?: true,
                     next_offset: 0,
                     total_height: block.height + acc.total_height - block.height_diff
                 }}

              block.rendered_new_state? ->
                {[Map.put(block, :offset, acc.next_offset)],
                 %{
                   acc
                   | next_offset: 0,
                     # - block.height_diff
                     total_height: block.height + acc.total_height
                 }}

              acc.next_offset == 0 and acc.total_height == 0 ->
                {[], acc}

              true ->
                {[],
                 %{
                   acc
                   | next_offset: acc.next_offset + block.height,
                     total_height: block.height + acc.total_height
                 }}
            end
          end
        )

      data =
        if content_blocks == [] do
          []
        else
          [
            if(rendered_above?, do: [], else: IO.ANSI.cursor_up(total_height)),
            content_blocks
            |> Enum.map(fn
              %{offset: 0, content: content} -> content
              %{offset: offset, content: content} -> [IO.ANSI.cursor_down(offset), content]
            end)
            |> Owl.Data.unlines(),
            if(return_to_end == 0, do: [], else: IO.ANSI.cursor_down(return_to_end))
          ]
        end

      {state, data}
    end
  end

  defp render_added_blocks(%{blocks_to_add: []} = state, _terminal_width, rows_left),
    do: {state, [], rows_left}

  defp render_added_blocks(state, terminal_width, rows_left) do
    {content_blocks, {state, rows_left}} =
      state.blocks_to_add
      |> Enum.reverse()
      |> Enum.flat_map_reduce({state, rows_left}, fn block_id, {state, rows_left} ->
        %{
          rendered_new_state?: true,
          block_content: block_content,
          block_height: block_height,
          new_rows_left: rows_left,
          content_to_render: content_to_render
        } = get_content(state, block_id, terminal_width, rows_left)

        content =
          if content_to_render == [] do
            []
          else
            [content_to_render]
          end

        {content,
         {%{
            state
            | rendered_content_height:
                Map.put(state.rendered_content_height, block_id, %{
                  max: block_height,
                  rendered: block_height
                }),
              content: Map.put(state.content, block_id, block_content)
          }, rows_left}}
      end)

    {state, Owl.Data.unlines(Enum.reverse(content_blocks)), rows_left}
  end

  defp empty_blocks_list?(state) do
    state.rendered_blocks == [] and state.blocks_to_add == []
  end
end
