defmodule Pdf.Page do
  @moduledoc false
  defstruct size: :a4,
            stream: nil,
            fonts: nil,
            current_font: nil,
            current_font_size: 0,
            fill_color: :black,
            leading: nil,
            cursor: 0,
            in_text: false,
            saved: %{},
            saving_state: false

  defdelegate table(page, data, xy, wh), to: Pdf.Table
  defdelegate table(page, data, xy, wh, opts), to: Pdf.Table
  defdelegate table!(page, data, xy, wh), to: Pdf.Table
  defdelegate table!(page, data, xy, wh, opts), to: Pdf.Table

  import Pdf.Utils
  alias Pdf.{Image, Fonts, Stream, Text, Font}

  def new(opts \\ [size: :a4]), do: init(opts, %__MODULE__{stream: Stream.new()})

  defp init([], page), do: page
  defp init([{:fonts, fonts} | tail], page), do: init(tail, %{page | fonts: fonts})

  defp init([{:size, size} | tail], page) do
    [_bottom, _left, _width, height] = Pdf.Paper.size(size)
    init(tail, %{page | size: size, cursor: height})
  end

  defp init([{:compress, false} | tail], page),
    do: init(tail, %{page | stream: Stream.new(compress: false)})

  defp init([{:compress, true} | tail], page),
    do: init(tail, %{page | stream: Stream.new(compress: 6)})

  defp init([{:compress, level} | tail], page),
    do: init(tail, %{page | stream: Stream.new(compress: level)})

  defp init([_ | tail], page), do: init(tail, page)

  def push(page, command), do: %{page | stream: Stream.push(page.stream, command)}

  def set_fill_color(%{fill_color: color} = page, color), do: page

  def set_fill_color(%{saving_state: true} = page, color) do
    push(page, color_command(color, fill_command(color)))
  end

  def set_fill_color(page, color) do
    push(%{page | fill_color: color}, color_command(color, fill_command(color)))
  end

  def set_stroke_color(page, color) do
    push(page, color_command(color, stroke_command(color)))
  end

  def set_line_width(page, width) do
    push(page, [width, "w"])
  end

  def set_line_cap(page, style) do
    push(page, [line_cap(style), "J"])
  end

  defp line_cap(:butt), do: 0
  defp line_cap(:round), do: 1
  defp line_cap(:projecting_square), do: 2
  defp line_cap(:square), do: 2
  defp line_cap(style), do: style

  def set_line_join(page, style) do
    push(page, [line_join(style), "j"])
  end

  defp line_join(:miter), do: 0
  defp line_join(:round), do: 1
  defp line_join(:bevel), do: 2
  defp line_join(style), do: style

  def rectangle(page, {x, y}, {w, h}) do
    push(page, [x, y, w, h, "re"])
  end

  def line(page, {x, y}, {x2, y2}) do
    page
    |> move_to({x, y})
    |> line_append({x2, y2})
  end

  def move_to(page, {x, y}) do
    push(page, [x, y, "m"])
  end

  def line_append(page, {x, y}) do
    push(page, [x, y, "l"])
  end

  def stroke(page) do
    push(page, ["S"])
  end

  def fill(page) do
    push(page, ["f"])
  end

  def set_font(%{fonts: fonts} = page, name, size, opts \\ []) do
    font = Fonts.get_font(fonts, name, opts)
    push_font(page, font, size)
  end

  defp push_font(%{current_font: font, current_font_size: size} = page, font, size), do: page

  defp push_font(%{in_text: true} = page, font, size) do
    push(%{page | current_font: font, current_font_size: size}, [font.name, size, "Tf"])
  end

  defp push_font(page, font, size) do
    %{page | current_font: font, current_font_size: size}
  end

  def set_font_size(page, size) do
    push_font_size(page, size)
  end

  defp push_font_size(%{current_font_size: size} = page, size), do: page

  defp push_font_size(%{in_text: true, current_font: font} = page, size) do
    push(%{page | current_font_size: size}, [font.name, size, "Tf"])
  end

  defp push_font_size(page, size) do
    %{page | current_font_size: size}
  end

  def set_text_leading(page, leading) do
    %{page | leading: leading}
  end

  defp begin_text(page) do
    %{current_font: font, current_font_size: size, leading: leading, fill_color: fill_color} =
      page

    %{
      page
      | in_text: true,
        saved: %{
          current_font: font,
          current_font_size: size,
          leading: leading,
          fill_color: fill_color
        }
    }
    |> push(["BT"])
    |> push([font.name, size, "Tf"])
  end

  defp end_text(%{in_text: true, saved: saved} = page) do
    page =
      page
      |> set_text_leading(Map.get(saved, :leading))
      |> set_fill_color(Map.get(saved, :fill_color))

    push(
      %{
        page
        | in_text: false,
          current_font: Map.get(saved, :current_font),
          current_font_size: Map.get(saved, :current_font_size),
          saved: %{}
      },
      ["ET"]
    )
  end

  def text_at(page, xy, text, opts \\ [])

  def text_at(page, {x, y}, attributed_text, opts) when is_list(attributed_text) do
    attributed_text = annotate_attributed_text(attributed_text, page, opts)

    page
    |> begin_text()
    |> push([x, y, "Td"])
    |> print_attributed_line(attributed_text)
    |> end_text()
    |> set_cursor(y - line_height(page, attributed_text))
  end

  def text_at(page, xy, text, opts) do
    text_at(page, xy, [text], opts)
  end

  defp merge_same_opts([]), do: []

  defp merge_same_opts([{text, width, opts}, {text2, width2, opts} | tail]) do
    merge_same_opts([{text <> text2, width + width2, opts} | tail])
  end

  defp merge_same_opts([chunk | tail]) do
    [chunk | merge_same_opts(tail)]
  end

  def annotate_attributed_text(nil, page, opts) do
    annotate_attributed_text([""], page, opts)
  end

  def annotate_attributed_text(text, page, opts) when is_binary(text) do
    annotate_attributed_text([text], page, opts)
  end

  def annotate_attributed_text(
        attributed_text,
        %{fonts: fonts, current_font: %{module: font}} = page,
        overall_opts
      )
      when is_list(attributed_text) do
    attributed_text
    |> Enum.map(fn
      str when is_binary(str) -> {str, []}
      {str} -> {str, []}
      {str, opts} -> {str, opts}
      annotated -> annotated
    end)
    |> Enum.map(fn
      {text, width, opts} ->
        {text, width, opts}

      {text, opts} ->
        opts = Keyword.merge(overall_opts, opts)

        font =
          if Enum.any?([:bold, :italic], &Keyword.has_key?(opts, &1)) do
            Fonts.get_font(fonts, font, Keyword.take(opts, [:bold, :italic]))
          else
            Fonts.get_font(fonts, font.name, [])
          end

        font_size = Keyword.get(opts, :font_size, page.current_font_size)
        leading = Keyword.get(opts, :leading, page.leading || page.current_font_size)
        color = Keyword.get(opts, :color, page.fill_color)

        height = Enum.max([leading, font_size])
        ascender = font.module.ascender * font_size / 1000
        descender = -(font.module.descender * font_size / 1000)
        cap_height = (font.module.cap_height || 0) * font_size / 1000
        x_height = (font.module.x_height || 0) * font_size / 1000
        line_gap = (font_size - (ascender + descender)) / 2

        width = Font.text_width(font.module, text, font_size, opts)

        {text, width,
         Keyword.merge(
           overall_opts,
           Keyword.merge(opts,
             ascender: ascender,
             cap_height: cap_height,
             color: color,
             descender: descender,
             font: font,
             height: height,
             line_gap: line_gap,
             font_size: font_size,
             leading: leading,
             x_height: x_height
           )
         )}
    end)
  end

  def annotate_attributed_text(attributed_text, %{current_font: nil}, _overall_opts)
      when is_list(attributed_text) do
    raise RuntimeError, "No font selected"
  end

  def annotate_attributed_text(non_string, page, overall_opts) do
    annotate_attributed_text(to_string(non_string), page, overall_opts)
  end

  def text_wrap!(page, xy, wh, text, opts \\ []) do
    case text_wrap(page, xy, wh, text, opts) do
      {page, :complete} -> page
      _ -> raise(RuntimeError, "The supplied text did not fit within the supplied boundary")
    end
  end

  def text_wrap(page, xy, wh, text, opts \\ [])

  def text_wrap(page, {x, :cursor}, wh, text, opts) do
    y = cursor(page)
    text_wrap(page, {x, y}, wh, text, opts)
  end

  def text_wrap(page, {x, y}, {w, h}, text, opts)
      when is_binary(text) do
    text_wrap(page, {x, y}, {w, h}, [text], opts)
  end

  def text_wrap(page, {x, y}, {w, h}, {:continue, [{:line, _line} | _] = lines}, opts) do
    text_wrap(page, {x, y}, {w, h}, lines, opts)
  end

  def text_wrap(page, {x, y}, {w, h}, [{:line, _line} | _] = lines, opts) do
    page
    |> begin_text()
    |> set_cursor(y)
    |> print_attributed_lines(lines, x, y, w, h, opts)
    |> complete_wrapping()
  end

  def text_wrap(page, {x, y}, {w, h}, {:continue, chunks}, opts) do
    page
    |> begin_text()
    |> set_cursor(y)
    |> print_attributed_chunks(chunks, x, y, w, h, opts)
    |> complete_wrapping()
  end

  def text_wrap(page, {x, y}, {w, h}, attributed_text, opts) when is_list(attributed_text) do
    attributed_text = annotate_attributed_text(attributed_text, page, opts)

    chunks = Text.chunk_attributed_text(attributed_text, opts)

    page
    |> begin_text()
    |> set_cursor(y)
    |> print_attributed_chunks(chunks, x, y, w, h, opts)
    |> complete_wrapping()
  end

  def complete_wrapping({page, []}) do
    {end_text(page), :complete}
  end

  def complete_wrapping({page, [_ | _] = remaining}) do
    {end_text(page), {:continue, remaining}}
  end

  defp line_height(page, attributed_text) do
    line_height = attributed_text |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max()
    Enum.max(Enum.filter([page.leading, line_height], & &1))
  end

  defp print_attributed_chunks(page, chunks, x, y, width, height, opts, prev_line_height \\ 0)

  defp print_attributed_chunks(page, [], _, _, _, _, _, _), do: {page, []}

  defp print_attributed_chunks(page, chunks, x, y, width, height, opts, prev_line_height) do
    {line, tail} =
      case Text.wrap_chunks(chunks, width) do
        {[], [line | tail]} ->
          {[line], tail}

        other ->
          other
      end

    line_width = Enum.reduce(line, 0, fn {_, width, _}, acc -> width + acc end)

    line_height =
      page.leading || line |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max()

    if line_height > height do
      # No available vertical space to print the line so return remaining lines
      {page, chunks}
    else
      x_offset =
        case Keyword.get(opts, :align, :left) do
          :left -> x
          :center -> x + (width - line_width) / 2
          :right -> x + (width - line_width)
        end

      ascender =
        line
        |> Enum.map(&Keyword.get(elem(&1, 2), :ascender))
        |> Enum.max()

      line_gap =
        line
        |> Enum.map(&Keyword.get(elem(&1, 2), :line_gap))
        |> Enum.max()

      y_offset = if prev_line_height == 0, do: y - (ascender + line_gap), else: -prev_line_height

      page
      |> push([x_offset, y_offset, "Td"])
      |> print_attributed_line(line)
      |> move_down(line_height)
      |> print_attributed_chunks(
        tail,
        x - x_offset,
        y - line_height,
        width,
        height - line_height,
        opts,
        line_height
      )
    end
  end

  defp print_attributed_lines(page, lines, x, y, width, height, opts, prev_line_height \\ 0)

  defp print_attributed_lines(page, [], _x, _y, _width, _height, _opts, _prev_line_height),
    do: {page, []}

  defp print_attributed_lines(
         page,
         [{:line, line} | lines],
         x,
         y,
         width,
         height,
         opts,
         prev_line_height
       ) do
    line_width = Enum.reduce(line, 0, fn {_, width, _}, acc -> width + acc end)

    line_height =
      page.leading || line |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max()

    if line_height > height do
      # No available vertical space to print the line so return remaining lines
      {page, [line | lines]}
    else
      x_offset =
        case Keyword.get(opts, :align, :left) do
          :left -> x
          :center -> x + (width - line_width) / 2
          :right -> x + (width - line_width)
        end

      ascender =
        line
        |> Enum.map(&Keyword.get(elem(&1, 2), :ascender))
        |> Enum.max()

      line_gap =
        line
        |> Enum.map(&Keyword.get(elem(&1, 2), :line_gap))
        |> Enum.max()

      y_offset = if prev_line_height == 0, do: y - (ascender + line_gap), else: -prev_line_height

      page
      |> push([x_offset, y_offset, "Td"])
      |> print_attributed_line(line)
      |> move_down(line_height)
      |> print_attributed_lines(
        lines,
        x - x_offset,
        y - line_height,
        width,
        height - line_height,
        opts,
        line_height
      )
    end
  end

  defp print_attributed_line(page, attributed_text) do
    attributed_text
    |> merge_same_opts
    |> Enum.reduce(page, fn {text, _width, opts}, page ->
      page
      |> set_font(opts[:font].module.name, opts[:font_size], opts)
      |> set_text_leading(opts[:leading])
      |> set_fill_color(opts[:color])
      |> push(kerned_text(opts[:font].module, text, opts))
    end)
  end

  def text_lines(page, xy, lines, opts \\ [])

  def text_lines(page, _xy, [], _opts), do: page

  def text_lines(page, {x, y}, lines, opts) do
    leading = page.leading || page.current_font_size

    page
    |> begin_text()
    |> push([x, y, "Td"])
    |> push([leading, "TL"])
    |> draw_lines(lines, opts)
    |> end_text()
  end

  def add_image(page, {x, y}, image, opts \\ []) do
    %{name: image_name, image: %Image{width: width, height: height}} = image
    scaled_width = Keyword.get(opts, :width, width) / width
    scaled_height = Keyword.get(opts, :height, height) / height
    width = Keyword.get(opts, :width, width * scaled_height)
    height = Keyword.get(opts, :height, height * scaled_width)

    page
    |> save_state()
    |> push([width, 0, 0, height, x, y, "cm"])
    |> push([image_name, "Do"])
    |> restore_state()
  end

  def clip(page) do
    push(page, ["W n"])
  end

  def save_state(page) do
    push(%{page | saving_state: true}, "q")
  end

  def restore_state(page) do
    push(%{page | saving_state: false}, "Q")
  end

  def size(%{size: size}) do
    [_bottom, _left, width, height] = Pdf.Paper.size(size)
    %{width: width, height: height}
  end

  def set_cursor(page, y) do
    %{page | cursor: y}
  end

  def move_down(%{cursor: y} = page, amount) do
    %{page | cursor: y - amount}
  end

  def cursor(%{cursor: cursor}) do
    cursor
  end

  defp kerned_text(font, text, opts) when is_list(opts) do
    text
    |> Text.normalize_string(Keyword.get(opts, :encoding_replacement_character, :raise))
    |> kern_text(font, Keyword.get(opts, :kerning, false))
  end

  defp kern_text(text, _font, false) do
    [s(Text.escape(text)), "Tj"]
  end

  defp kern_text(text, font, true) do
    text =
      font
      |> Font.kern_text(text)
      |> Text.escape()
      |> Enum.map(fn
        str when is_binary(str) -> s(str)
        num -> num
      end)

    [Pdf.Array.new(text), "TJ"]
  end

  defp draw_lines(%{current_font: %{module: font}} = page, [line], opts) do
    push(page, kerned_text(font, line, opts))
  end

  defp draw_lines(%{current_font: %{module: font}} = page, [line | tail], opts) do
    text = kerned_text(font, line, opts)
    draw_lines(push(page, text ++ ["T*"]), tail, opts)
  end

  defp color_command(color_name, command) when is_atom(color_name) do
    color = Pdf.Color.color(color_name)
    color_command(color, command)
  end

  defp color_command({r, g, b}, command) when is_integer(r) and is_integer(g) and is_integer(b) do
    [r / 255.0, g / 255.0, b / 255.0, command]
  end

  defp color_command({r, g, b}, command) when is_float(r) and is_float(g) and is_float(b) do
    [r, g, b, command]
  end

  defp color_command({r, g, b}, command) when is_float(r) and is_float(g) and is_float(b) do
    [r, g, b, command]
  end

  defp color_command({c, m, y, k}, command)
       when is_float(c) and is_float(m) and is_float(y) and is_float(k) do
    [c, m, y, k, command]
  end

  defp fill_command(color_name) when is_atom(color_name), do: "rg"
  defp fill_command({_r, _g, _b}), do: "rg"
  defp fill_command({_c, _m, _y, _k}), do: "k"

  defp stroke_command(color_name) when is_atom(color_name), do: "RG"
  defp stroke_command({_r, _g, _b}), do: "RG"
  defp stroke_command({_c, _m, _y, _k}), do: "K"

  defimpl Pdf.Size do
    def size_of(%Pdf.Page{} = page), do: Pdf.Size.size_of(page.stream)
  end

  defimpl Pdf.Export do
    def to_iolist(%Pdf.Page{} = page), do: Pdf.Export.to_iolist(page.stream)
  end

  defimpl Inspect do
    def inspect(%Pdf.Page{size: size}, _opts), do: "#Page<size: #{inspect(size)}>"
  end
end
