lib/pdf/styled_table.ex

defmodule Pdf.StyledTable do
  @moduledoc """
  Styled table component with CSS-like configuration.

  Renders data tables with customizable borders, rounded corners, backgrounds,
  padding, and per-row/cell styling using `Pdf.Style` maps.

  ## Example

      Pdf.StyledTable.render(doc, [
        ["Name", "Qty", "Price"],
        ["Widget A", "5", "$10.00"],
        ["Widget B", "3", "$15.00"]
      ], %{
        columns: [
          %{width: 200},
          %{width: 80, align: :center},
          %{width: 120, align: :right}
        ],
        header: %{bold: true, background: {0.2, 0.3, 0.5}, color: :white, padding: 8},
        row: %{padding: 6, border_bottom: 1},
        alt_row: %{background: {0.95, 0.95, 1.0}},
        border: 1,
        border_color: {0.3, 0.3, 0.3},
        border_radius: 6
      })
  """

  alias Pdf.{Page, Style}

  @default_opts %{
    columns: [],
    header: nil,
    row: %{},
    alt_row: nil,
    footer: nil,
    border: 0,
    border_color: :black,
    border_radius: 0,
    background: nil,
    padding: {4, 6, 4, 6},
    font: "Helvetica",
    font_size: 10,
    color: :black,
    line_height: 14
  }

  @doc """
  Render a styled table on the document at the current cursor position.

  Returns the updated document with cursor moved below the table.

  ## Options

  - `:columns` — list of column config maps: `%{width: n, align: :left|:center|:right, style: %{}}`
  - `:header` — style map for header row (first row of data), or `nil` to treat all rows as body
  - `:row` — default style map for body rows
  - `:alt_row` — style map merged into every other body row (zebra stripes)
  - `:footer` — style map for the last row
  - `:border` — outer border width (number)
  - `:border_color` — outer border color
  - `:border_radius` — corner radius for outer border
  - `:background` — default cell background
  - `:padding` — default cell padding (CSS shorthand)
  - `:font`, `:font_size`, `:color` — default text styling
  - `:line_height` — height per text line in points
  """
  def render(document, data, opts \\ %{}) when is_list(data) do
    opts = Map.merge(@default_opts, opts)
    pos = Pdf.cursor_xy(document)
    area = Pdf.content_area(document)

    # Calculate column widths
    cols = resolve_columns(data, opts, area.width)
    total_width = Enum.reduce(cols, 0, fn col, acc -> acc + col.width end)

    # Calculate row heights
    rows = prepare_rows(data, opts)
    total_height = Enum.reduce(rows, 0, fn row, acc -> acc + row.height end)

    table_x = pos.x
    table_y = pos.y
    r = opts.border_radius

    # 1) Draw row backgrounds (clipped to rounded rect when border_radius > 0)
    document =
      draw_clipped_backgrounds(
        document,
        rows,
        cols,
        {table_x, table_y},
        {total_width, total_height},
        r,
        opts
      )

    # 2) Draw row borders + text (not clipped)
    {document, _y} =
      Enum.reduce(rows, {document, table_y}, fn row, {doc, y} ->
        doc = draw_row_content(doc, row, cols, {table_x, y}, opts)
        {doc, y - row.height}
      end)

    # 3) Draw outer border stroke on top
    document = draw_outer_border(document, {table_x, table_y}, {total_width, total_height}, opts)

    # Move cursor below table
    Pdf.set_cursor(document, table_y - total_height)
  end

  @doc """
  Render a styled table on a Page struct (low-level).
  """
  def render_on_page(page, {x, y}, data, opts \\ %{}) do
    opts = Map.merge(@default_opts, opts)

    cols = resolve_columns_with_total(data, opts)
    total_width = Enum.reduce(cols, 0, fn col, acc -> acc + col.width end)

    rows = prepare_rows(data, opts)
    total_height = Enum.reduce(rows, 0, fn row, acc -> acc + row.height end)
    r = opts.border_radius

    # 1) Clipped backgrounds
    page =
      draw_clipped_backgrounds_page(
        page,
        rows,
        cols,
        {x, y},
        {total_width, total_height},
        r,
        opts
      )

    # 2) Row borders + text
    {page, _y} =
      Enum.reduce(rows, {page, y}, fn row, {pg, cy} ->
        pg = draw_row_content_page(pg, row, cols, {x, cy}, opts)
        {pg, cy - row.height}
      end)

    # 3) Outer border on top
    draw_outer_border_page(page, {x, y}, {total_width, total_height}, opts)
  end

  # ── Column resolution ──────────────────────────────────────────────

  defp resolve_columns(data, opts, available_width) do
    num_cols = data |> List.first([]) |> length()
    col_defs = opts.columns

    Enum.map(0..(num_cols - 1), fn i ->
      col_def = Enum.at(col_defs, i, %{})
      width = Map.get(col_def, :width)

      %{
        index: i,
        width: width,
        align: Map.get(col_def, :align, :left),
        style: Map.get(col_def, :style, %{})
      }
    end)
    |> distribute_widths(available_width)
  end

  defp resolve_columns_with_total(data, opts) do
    num_cols = data |> List.first([]) |> length()
    col_defs = opts.columns
    total = Enum.reduce(col_defs, 0, fn c, acc -> acc + Map.get(c, :width, 100) end)

    Enum.map(0..(num_cols - 1), fn i ->
      col_def = Enum.at(col_defs, i, %{})

      %{
        index: i,
        width: Map.get(col_def, :width, total / num_cols),
        align: Map.get(col_def, :align, :left),
        style: Map.get(col_def, :style, %{})
      }
    end)
  end

  defp distribute_widths(cols, available_width) do
    {fixed, flexible} = Enum.split_with(cols, fn c -> c.width != nil end)
    fixed_total = Enum.reduce(fixed, 0, fn c, acc -> acc + c.width end)
    remaining = available_width - fixed_total
    flex_count = length(flexible)

    if flex_count > 0 do
      flex_width = remaining / flex_count

      Enum.map(cols, fn c ->
        if c.width == nil, do: %{c | width: flex_width}, else: c
      end)
    else
      cols
    end
  end

  # ── Row preparation ────────────────────────────────────────────────

  defp prepare_rows(data, opts) do
    total = length(data)
    has_header = opts.header != nil
    has_footer = opts.footer != nil

    data
    |> Enum.with_index()
    |> Enum.map(fn {cells, idx} ->
      row_type =
        cond do
          has_header and idx == 0 -> :header
          has_footer and idx == total - 1 -> :footer
          true -> :body
        end

      body_idx = if has_header, do: idx - 1, else: idx
      is_alt = rem(body_idx, 2) == 1

      row_style = row_style_for(row_type, is_alt, opts)
      padding = Style.expand_shorthand(Map.get(row_style, :padding, opts.padding))
      {pt, _pr, pb, _pl} = padding
      line_h = Map.get(row_style, :line_height, opts.line_height)

      %{
        cells: cells,
        type: row_type,
        style: row_style,
        height: pt + line_h + pb,
        padding: padding
      }
    end)
  end

  defp row_style_for(:header, _is_alt, opts), do: opts.header || %{}
  defp row_style_for(:footer, _is_alt, opts), do: Map.merge(opts.row, opts.footer || %{})

  defp row_style_for(:body, true, opts) do
    if opts.alt_row, do: Map.merge(opts.row, opts.alt_row), else: opts.row
  end

  defp row_style_for(:body, false, opts), do: opts.row

  # ── Drawing (Document level) ───────────────────────────────────────

  defp draw_clipped_backgrounds(document, rows, cols, {table_x, table_y}, {w, h}, r, opts) do
    # Draw table-level background
    document =
      if opts.background do
        document
        |> Pdf.save_state()
        |> Pdf.set_fill_color(opts.background)
        |> draw_shape(document, {table_x, table_y - h}, {w, h}, r)
        |> Pdf.fill()
        |> Pdf.restore_state()
      else
        document
      end

    # Check if any row has a background
    has_row_bg = Enum.any?(rows, fn row -> Map.get(row.style, :background) != nil end)

    if has_row_bg do
      # Save state, set clipping path (rounded rect), then draw row backgrounds
      document =
        document
        |> Pdf.save_state()
        |> draw_shape(document, {table_x, table_y - h}, {w, h}, r)
        |> Pdf.clip()

      # Draw each row background inside the clip
      {document, _y} =
        Enum.reduce(rows, {document, table_y}, fn row, {doc, y} ->
          row_h = row.height
          row_y = y - row_h

          doc =
            case Map.get(row.style, :background) do
              nil ->
                doc

              bg ->
                doc
                |> Pdf.save_state()
                |> Pdf.set_fill_color(bg)
                |> Pdf.Document.rectangle({table_x, row_y}, {total_width(cols), row_h})
                |> Pdf.fill()
                |> Pdf.restore_state()
            end

          {doc, y - row_h}
        end)

      Pdf.restore_state(document)
    else
      document
    end
  end

  defp draw_outer_border(document, {x, y}, {w, h}, opts) do
    border = opts.border
    r = opts.border_radius

    if border > 0 do
      document
      |> Pdf.save_state()
      |> Pdf.set_stroke_color(opts.border_color)
      |> Pdf.set_line_width(border)
      |> draw_shape(document, {x, y - h}, {w, h}, r)
      |> Pdf.stroke()
      |> Pdf.restore_state()
    else
      document
    end
  end

  defp draw_shape(doc, _doc_ref, {x, y}, {w, h}, r) when r > 0 do
    Pdf.Document.rounded_rectangle(doc, {x, y}, {w, h}, r)
  end

  defp draw_shape(doc, _doc_ref, {x, y}, {w, h}, _r) do
    Pdf.Document.rectangle(doc, {x, y}, {w, h})
  end

  defp draw_row_content(document, row, cols, {table_x, y}, opts) do
    {pt, _pr, _pb, _pl} = row.padding
    row_h = row.height
    row_y = y - row_h

    # Row bottom border
    border_bottom = Map.get(row.style, :border_bottom, 0)

    document =
      if border_bottom > 0 do
        bc = Map.get(row.style, :border_color, Map.get(opts, :border_color, :black))

        document
        |> Pdf.save_state()
        |> Pdf.set_stroke_color(bc)
        |> Pdf.set_line_width(border_bottom)
        |> Pdf.line({table_x, row_y}, {table_x + total_width(cols), row_y})
        |> Pdf.stroke()
        |> Pdf.restore_state()
      else
        document
      end

    # Draw cells
    font = Map.get(row.style, :font, opts.font)
    font_size = Map.get(row.style, :font_size, opts.font_size)
    bold = Map.get(row.style, :bold, false)
    italic = Map.get(row.style, :italic, false)
    color = Map.get(row.style, :color, opts.color)

    document = Pdf.set_font(document, font, font_size, bold: bold, italic: italic)
    document = Pdf.set_fill_color(document, color)

    {document, _x} =
      Enum.reduce(cols, {document, table_x}, fn col, {doc, cx} ->
        cell_text = Enum.at(row.cells, col.index, "")
        {_pt, pr, _pb, pl} = row.padding

        text_x =
          cell_text_x(
            cx,
            pl,
            pr,
            col.width,
            col.align,
            cell_text,
            font,
            font_size,
            bold,
            italic,
            doc
          )

        text_y = y - pt - font_size

        doc = Pdf.text_at(doc, {text_x, text_y}, cell_text)
        {doc, cx + col.width}
      end)

    document
  end

  defp cell_text_x(cx, pl, _pr, _col_w, :left, _text, _font, _fs, _b, _i, _doc) do
    cx + pl
  end

  defp cell_text_x(cx, pl, pr, col_w, :center, text, font, font_size, bold, italic, doc) do
    text_w = estimate_text_width(text, font, font_size, bold, italic, doc)
    inner = col_w - pl - pr
    cx + pl + (inner - text_w) / 2
  end

  defp cell_text_x(cx, _pl, pr, col_w, :right, text, font, font_size, bold, italic, doc) do
    text_w = estimate_text_width(text, font, font_size, bold, italic, doc)
    cx + col_w - pr - text_w
  end

  defp estimate_text_width(text, _font, font_size, _bold, _italic, _doc) do
    # Approximate: average char width ~0.5 * font_size for Helvetica
    String.length(text) * font_size * 0.5
  end

  defp total_width(cols), do: Enum.reduce(cols, 0, fn c, acc -> acc + c.width end)

  # ── Drawing (Page level) ───────────────────────────────────────────

  defp draw_shape_page(page, {x, y}, {w, h}, r) when r > 0 do
    Page.rounded_rectangle(page, {x, y}, {w, h}, r)
  end

  defp draw_shape_page(page, {x, y}, {w, h}, _r) do
    Page.rectangle(page, {x, y}, {w, h})
  end

  defp draw_clipped_backgrounds_page(page, rows, cols, {table_x, table_y}, {w, h}, r, opts) do
    page =
      if opts.background do
        page
        |> Page.save_state()
        |> Page.set_fill_color(opts.background)
        |> draw_shape_page({table_x, table_y - h}, {w, h}, r)
        |> Page.fill()
        |> Page.restore_state()
      else
        page
      end

    has_row_bg = Enum.any?(rows, fn row -> Map.get(row.style, :background) != nil end)

    if has_row_bg do
      page =
        page
        |> Page.save_state()
        |> draw_shape_page({table_x, table_y - h}, {w, h}, r)
        |> Page.clip()

      {page, _y} =
        Enum.reduce(rows, {page, table_y}, fn row, {pg, y} ->
          row_h = row.height
          row_y = y - row_h

          pg =
            case Map.get(row.style, :background) do
              nil ->
                pg

              bg ->
                pg
                |> Page.save_state()
                |> Page.set_fill_color(bg)
                |> Page.rectangle({table_x, row_y}, {total_width(cols), row_h})
                |> Page.fill()
                |> Page.restore_state()
            end

          {pg, y - row_h}
        end)

      Page.restore_state(page)
    else
      page
    end
  end

  defp draw_outer_border_page(page, {x, y}, {w, h}, opts) do
    border = opts.border
    r = opts.border_radius

    if border > 0 do
      page
      |> Page.save_state()
      |> Page.set_stroke_color(opts.border_color)
      |> Page.set_line_width(border)
      |> draw_shape_page({x, y - h}, {w, h}, r)
      |> Page.stroke()
      |> Page.restore_state()
    else
      page
    end
  end

  defp draw_row_content_page(page, row, cols, {table_x, y}, opts) do
    {pt, _pr, _pb, _pl} = row.padding
    row_h = row.height
    row_y = y - row_h

    border_bottom = Map.get(row.style, :border_bottom, 0)

    page =
      if border_bottom > 0 do
        bc = Map.get(row.style, :border_color, Map.get(opts, :border_color, :black))

        page
        |> Page.save_state()
        |> Page.set_stroke_color(bc)
        |> Page.set_line_width(border_bottom)
        |> Page.line({table_x, row_y}, {table_x + total_width(cols), row_y})
        |> Page.stroke()
        |> Page.restore_state()
      else
        page
      end

    font = Map.get(row.style, :font, opts.font)
    font_size = Map.get(row.style, :font_size, opts.font_size)

    {page, _x} =
      Enum.reduce(cols, {page, table_x}, fn col, {pg, cx} ->
        cell_text = Enum.at(row.cells, col.index, "")
        {_pt, _pr, _pb, pl} = row.padding
        text_x = cx + pl
        text_y = y - pt - font_size

        pg = Page.text_at(pg, {text_x, text_y}, cell_text, font: font, size: font_size)
        {pg, cx + col.width}
      end)

    page
  end
end