defmodule ExTerm.Console.StringTracker do
  @moduledoc false

  # special private module that encapsulates and stores compound string
  # operations on the console.  This module allows code sharing between the
  # put_iodata and insert_iodata functionality.  It tracks a set of changes that
  # are directed by an input string (e.g. unicode characters put at cursor
  # location, style changes, etc.) and virtualizes them into a list of
  # cellinfo changes to be flushed to the console later.
  #
  # if the StringTracker runs into a console control character, it will eject
  # from the normal change buffering mode and paint directly onto the console,
  # possibly updating the column width (but only if expansion is needed).
  #
  # Private exported interface:
  #
  # - new
  # - put_string_rows
  # - insert_string_rows
  # - flush_updates
  #
  # Testable but private:
  #
  # - _blit_string_row

  use MatchSpec

  alias ExTerm.ANSI
  alias ExTerm.Console
  alias ExTerm.Console.Cell
  alias ExTerm.Console.Update

  require Console

  @enforce_keys [
    :console,
    :mode,
    :style,
    :cursor,
    :old_cursor,
    :layout,
    :last_cell
  ]

  defstruct @enforce_keys ++ [update: %Update{}, cells: [], rows_inserted: 1]

  @typep put :: :put
  @typep paint :: :paint
  @typep insert :: {:insert, from_row :: pos_integer()}

  @opaque state(mode_subtype) :: %__MODULE__{
            console: Console.t(),
            mode: mode_subtype,
            style: Style.t(),
            cursor: Console.location(),
            old_cursor: Console.location(),
            layout: Console.location(),
            last_cell: Console.location(),
            update: Update.t(),
            cells: [Console.cellinfo()],
            rows_inserted: pos_integer()
          }

  @opaque state() :: state(:put) | state(:paint) | state(:insert)

  @spec new(Console.t(), nil) :: state(:put)
  @spec new(Console.t(), pos_integer) :: state(:insert)
  def new(console, insertion \\ nil) do
    [cursor: old_cursor, layout: layout, style: style] =
      Console.get_metadata(console, [:cursor, :layout, :style])

    {new_cursor, mode} =
      if insertion do
        {{insertion, 1}, {:insert, insertion}}
      else
        {old_cursor, :put}
      end

    last_cell = Console.last_cell(console)

    %__MODULE__{
      mode: mode,
      cursor: new_cursor,
      old_cursor: old_cursor,
      style: style,
      console: console,
      layout: layout,
      last_cell: last_cell
    }
  end

  @spec put_string_rows(state(put), String.t()) :: state(put) | state(paint)
  def put_string_rows(tracker = %{mode: :put, cursor: {row, _}}, string) do
    # NB: don't cache the number of rows.  Row length should be fixed based on
    # the existing capacity of the column, so we have to check each time.

    case _blit_string_row(tracker, Console.columns(tracker.console, row), string) do
      # exhausted the row without finishing the string
      {updated_tracker, leftover} ->
        put_string_rows(updated_tracker, leftover)

      done ->
        done
    end
  end

  @spec insert_string_rows(state(insert), String.t()) :: state(insert) | state(paint)
  def insert_string_rows(tracker = %{mode: {:insert, _}, cursor: {row, _}}, string) do
    # NB: don't cache the number of columns.  Row length should be fixed based
    # on the existing capacity of the column, so we have to check each time.

    # for string row insertion, we're going to always add the row in to the cells
    # buffer, because this will always be "in-order".
    columns = Console.columns(tracker.console, row)

    case _blit_string_row(tracker, columns, string) do
      {new_tracker, leftover} ->
        insert_string_rows(new_tracker, leftover)

      done ->
        hard_return(done, columns)
    end
  end

  @spec flush_updates(state(insert)) :: Range.t()
  @spec flush_updates(state(put)) :: :ok
  @spec flush_updates(state(paint)) :: :ok
  def flush_updates(
        tracker = %{
          mode: {:insert, from_row},
          rows_inserted: rows,
          cells: cells,
          console: console,
          layout: {_, columns}
        }
      ) do
    # note that count has an extra because we do a hard return
    # at the end of an insert in all cases.

    cells =
      cells
      |> Enum.group_by(fn {{row, _}, _} -> row end)
      |> Enum.flat_map(fn {row, cells} ->
        columns =
          case Console.columns(console, row) do
            0 -> columns
            existing_columns -> existing_columns
          end

        cells
        |> Enum.sort()
        |> fill_row(row, columns)
      end)

    update_cells =
      console
      |> Console._bump_rows(from_row, rows - 1)
      |> Enum.reverse(cells)

    new_cursor =
      case old_cursor = tracker.old_cursor do
        {row, _} when row < from_row ->
          old_cursor

        {row, column} ->
          {row + rows - 1, column}
      end

    console
    |> Console.insert(update_cells)
    |> Console.move_cursor(new_cursor)

    range = from_row..(from_row + rows - 1)

    Update.set_insertion(range)
    Update.merge(tracker.update)

    range
  end

  def flush_updates(tracker) do
    tracker.console
    |> Console.insert(tracker.cells)
    |> Console.move_cursor(tracker.cursor)

    Update.merge(tracker.update)
    :ok
  end

  defp fill_row(cells, row, columns, so_far \\ [])

  defp fill_row([], row, columns, _so_far) do
    list = for column <- columns..1//-1, do: {{row, column}, %Cell{}}
    add_sentinel(list, row, columns)
  end

  defp fill_row([last = {{_, last_column}, _}], row, columns, so_far) do
    list =
      for column <- last_column..columns, reduce: [last | so_far] do
        acc -> [{{row, column}, %Cell{}} | acc]
      end

    add_sentinel(list, row, columns)
  end

  defp fill_row([head | rest], row, columns, so_far),
    do: fill_row(rest, row, columns, [head | so_far])

  defp add_sentinel(list, row, columns) do
    [{{row, columns + 1}, Cell.sentinel()} | list]
  end

  @spec _blit_string_row(state(put), pos_integer, String.t()) :: state(put) | state(paint)
  @spec _blit_string_row(state(insert), pos_integer, String.t()) :: state(insert) | state(paint)
  @spec _blit_string_row(state(paint), pos_integer, String.t()) :: state(paint)
  def _blit_string_row(tracker = %{cursor: {row, column}}, columns, "")
      when column !== 1 and column === columns + 1 do
    %{tracker | cursor: {row + 1, 1}}
  end

  def _blit_string_row(tracker, _, ""), do: tracker

  def _blit_string_row(tracker = %{cursor: {row, _}, layout: {_, columns}}, 0, string) do
    if tracker.mode === :put do
      Console.new_row(tracker.console, :end)
    end

    tracker
    |> Map.update!(:update, &Update.merge_changes(&1, {{row, 1}, {row, :end}}))
    |> _blit_string_row(columns, string)
  end

  def _blit_string_row(tracker = %{cursor: cursor = {row, column}}, columns, string)
      when column > columns do
    # make sure that the update reflects that this is the end line
    new_update =
      tracker
      |> Map.replace!(:update, Update.merge_changes(tracker.update, {cursor, {row, :end}}))
      |> hard_return(columns)

    {new_update, string}
  end

  def _blit_string_row(tracker, columns, "\t" <> rest) do
    _blit_string_row(hard_tab(tracker), columns, rest)
  end

  def _blit_string_row(tracker, columns, "\r\n" <> rest) do
    {hard_return(tracker, columns), rest}
  end

  def _blit_string_row(tracker, columns, "\r" <> rest) do
    {hard_return(tracker, columns), rest}
  end

  def _blit_string_row(tracker, columns, "\n" <> rest) do
    {hard_return(tracker, columns), rest}
  end

  def _blit_string_row(tracker, columns, string = "\e" <> _) do
    # TODO: refactor this to be less indirect
    tracker.console
    |> ANSI.new(tracker.style)
    |> ANSI.parse(string, tracker.cursor, tracker.cells)
    |> case do
      # no cursor change.
      {:style, ansi_state, rest} ->
        _blit_string_row(%{tracker | style: ANSI.style(ansi_state)}, columns, rest)

      {:update, new_cursor, changes, rest} ->
        new_update = Update.merge_changes(tracker.update, changes)

        _blit_string_row(
          %{tracker | cursor: new_cursor, update: new_update, cells: []},
          columns,
          rest
        )
    end
  end

  def _blit_string_row(tracker = %{cursor: cursor = {row, column}}, columns, string) do
    case String.next_grapheme(string) do
      {grapheme, rest} ->
        new_cells = [{cursor, %Cell{char: grapheme, style: tracker.style}} | tracker.cells]

        new_update = Update.merge_changes(tracker.update, cursor)

        tracker
        |> Map.merge(%{cursor: {row, column + 1}, update: new_update, cells: new_cells})
        |> _blit_string_row(columns, rest)
    end
  end

  # special events that are common
  defp hard_tab(
         tracker = %{
           cursor: {row, column},
           last_cell: last_cell = {last_row, _},
           layout: {_, layout_columns},
           console: console
         }
       ) do
    new_last_cell =
      if row === last_row + 1 do
        Console.new_row(console)
        {row, layout_columns}
      else
        last_cell
      end

    new_cursor = {row, tab_destination(column, 10)}

    %{
      tracker
      | cursor: new_cursor,
        update: %{tracker.update | cursor: new_cursor},
        last_cell: new_last_cell
    }
  end

  defp hard_return(tracker = %{cursor: {row, column}, mode: {:insert, _start}}, columns)
       when column < columns do
    new_cells = Enum.reduce(column..columns, tracker.cells, &[{{row, &1}, %Cell{}} | &2])

    %{tracker | cursor: {row + 1, 1}, cells: new_cells, rows_inserted: tracker.rows_inserted + 1}
  end

  defp hard_return(
         tracker = %{
           cursor: {row, _},
           last_cell: last_cell = {last_row, _},
           console: console,
           layout: {_, layout_columns},
           update: update
         },
         _
       ) do
    {new_last_cell, new_update} =
      if row === last_row + 1 do
        Console.new_row(console)
        {{row, layout_columns}, Update.merge_changes(update, {{row, 1}, :end})}
      else
        {last_cell, update}
      end

    %{
      tracker
      | cursor: {row + 1, 1},
        rows_inserted: tracker.rows_inserted + 1,
        last_cell: new_last_cell,
        update: new_update
    }
  end

  defp tab_destination(column, tab_length) do
    (div(column, tab_length) + 1) * tab_length
  end
end
