defmodule Mv.Membership.MembersPDF do
  @moduledoc """
  Exports members to PDF using Typst templates and Imprintor.

  Uses the same data structure as `MemberExport.Build` and converts it
  to the format expected by the Typst template. Handles internationalization
  for PDF-specific labels (title, metadata) and membership fee status.

  Ensures deterministic output by maintaining column and row order.

  Creates a temporary directory per request and copies the template there
  to avoid symlink issues and ensure isolation.
  """

  require Logger

  use Gettext, backend: MvWeb.Gettext

  alias Mv.Config

  @template_filename "members_export.typ"

  @doc """
  Renders export data to PDF binary.

  - `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
  - `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club")

  Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.

  The PDF binary starts with "%PDF" (PDF magic bytes).

  Validates row count against configured limit before processing.
  """
  @spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()}
  def render(export_data, opts \\ []) do
    row_count = length(export_data.rows)
    max_rows = Config.pdf_export_row_limit()

    if row_count > max_rows do
      Logger.warning(
        "PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})",
        error_type: :row_limit_exceeded
      )

      {:error, {:row_limit_exceeded, row_count, max_rows}}
    else
      Logger.info(
        "Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
      )

      locale = Keyword.get(opts, :locale, "en")
      club_name = Keyword.get(opts, :club_name, "Club")
      create_and_use_temp_directory(export_data, locale, club_name)
    end
  end

  defp create_and_use_temp_directory(export_data, locale, club_name) do
    case create_temp_directory() do
      {:ok, temp_dir} ->
        try do
          with {:ok, template_content} <- load_template(),
               {:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
               {:ok, template_data} <-
                 convert_to_template_format(export_data, locale, club_name),
               {:ok, config} <-
                 build_imprintor_config(template_content, template_data, temp_dir),
               {:ok, pdf_binary} <- compile_to_pdf(config) do
            Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")

            {:ok, pdf_binary}
          else
            {:error, reason} = error ->
              Logger.error("PDF export failed: #{inspect(reason)}",
                error_type: :pdf_export_failed
              )

              error
          end
        after
          cleanup_temp_directory(temp_dir)
        end

      {:error, reason} = error ->
        Logger.error("Failed to create temp directory: #{inspect(reason)}",
          error_type: :temp_dir_creation_failed
        )

        error
    end
  end

  defp create_temp_directory do
    # Create unique temp directory per request
    temp_base = System.tmp_dir!()
    temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")

    case File.mkdir_p(temp_dir) do
      :ok -> {:ok, temp_dir}
      {:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
    end
  end

  defp load_template do
    # Try multiple paths: compiled app path and source path (for tests/dev)
    template_paths = [
      Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
      Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
    ]

    Enum.reduce_while(template_paths, nil, fn path, _acc ->
      case File.read(path) do
        {:ok, content} -> {:halt, {:ok, content}}
        {:error, _reason} -> {:cont, nil}
      end
    end)
    |> case do
      {:ok, content} -> {:ok, content}
      nil -> {:error, {:template_not_found, :enoent}}
    end
  end

  defp copy_template_to_temp(temp_dir, template_content) do
    # Write template to temp directory (no symlinks, actual file copy)
    template_path = Path.join(temp_dir, @template_filename)

    case File.write(template_path, template_content) do
      :ok -> {:ok, template_path}
      {:error, reason} -> {:error, {:template_copy_failed, reason}}
    end
  end

  defp cleanup_temp_directory(temp_dir) do
    # Clean up temp directory and all contents
    case File.rm_rf(temp_dir) do
      {:ok, _} ->
        :ok

      {:error, reason, _} ->
        Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
    end
  end

  defp convert_to_template_format(export_data, locale, club_name) do
    # Set locale for translations
    Gettext.put_locale(MvWeb.Gettext, locale)

    headers = Enum.map(export_data.columns, & &1.label)
    column_count = length(export_data.columns)

    meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}

    generated_at_raw =
      Map.get(meta, :generated_at) ||
        Map.get(meta, "generated_at") ||
        DateTime.utc_now() |> DateTime.to_iso8601()

    generated_at = format_datetime(generated_at_raw, locale)

    member_count =
      Map.get(meta, :member_count) ||
        Map.get(meta, "member_count") ||
        length(export_data.rows)

    # Translate membership fee status and format dates in rows
    rows =
      export_data.rows
      |> translate_membership_fee_status_in_rows(export_data.columns)
      |> format_dates_in_rows(export_data.columns, locale)

    # Build title based on locale
    title = build_title(locale, club_name)

    # Build translated labels for metadata
    created_at_label = gettext("Created at:")
    member_count_label = gettext("Member count:")

    template_data = %{
      "title" => title,
      "created_at_label" => created_at_label,
      "member_count_label" => member_count_label,
      "generated_at" => generated_at,
      "column_count" => column_count,
      "headers" => headers,
      "rows" => rows,
      "columns" =>
        Enum.map(export_data.columns, fn col ->
          %{
            "key" => to_string(col.key),
            "kind" => to_string(col.kind),
            "label" => col.label
          }
        end),
      "meta" => %{
        "generated_at" => generated_at,
        "member_count" => member_count
      },
      "locale" => locale
    }

    {:ok, template_data}
  end

  defp build_title(_locale, club_name) do
    gettext("Member %{club_name}", club_name: club_name)
  end

  defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do
    # Try to parse as DateTime first
    case DateTime.from_iso8601(iso8601_string) do
      {:ok, datetime, _offset} ->
        format_datetime(datetime, locale)

      {:ok, datetime} ->
        format_datetime(datetime, locale)

      {:error, _} ->
        # Try NaiveDateTime if DateTime parsing fails
        case NaiveDateTime.from_iso8601(iso8601_string) do
          {:ok, naive_dt} ->
            # Convert to DateTime in UTC
            datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
            format_datetime(datetime, locale)

          {:error, _} ->
            # If both fail, return original string
            iso8601_string
        end
    end
  end

  defp format_datetime(%DateTime{} = datetime, locale) do
    # Format as readable date and time (locale-specific)
    case locale do
      "de" ->
        # German format: dd.mm.yyyy - HH:MM Uhr
        Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr")

      _ ->
        # English format: MM/DD/YYYY HH:MM AM/PM
        Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p")
    end
  end

  defp format_datetime(_, _), do: ""

  defp format_date(%Date{} = date, locale) do
    # Format as readable date (locale-specific)
    case locale do
      "de" ->
        # German format: dd.mm.yyyy
        Calendar.strftime(date, "%d.%m.%Y")

      _ ->
        # English format: MM/DD/YYYY
        Calendar.strftime(date, "%m/%d/%Y")
    end
  end

  defp format_date(_, _), do: ""

  defp format_dates_in_rows(rows, columns, locale) do
    date_indices = find_date_column_indices(columns)

    if date_indices == [] do
      rows
    else
      format_rows_dates(rows, date_indices, locale)
    end
  end

  defp find_date_column_indices(columns) do
    columns
    |> Enum.with_index()
    |> Enum.filter(fn {col, _idx} -> date_column?(col) end)
    |> Enum.map(fn {_col, idx} -> idx end)
  end

  defp format_rows_dates(rows, date_indices, locale) do
    Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end)
  end

  defp format_row_dates(row, date_indices, locale) do
    Enum.with_index(row)
    |> Enum.map(fn {cell_value, idx} ->
      if idx in date_indices do
        format_cell_date(cell_value, locale)
      else
        cell_value
      end
    end)
  end

  defp date_column?(%{kind: :member_field, key: key}) do
    key_atom = key_to_atom_safe(key)
    key_atom in [:join_date, :exit_date, :membership_fee_start_date]
  end

  defp date_column?(_), do: false

  defp key_to_atom_safe(key) when is_binary(key) do
    try do
      String.to_existing_atom(key)
    rescue
      ArgumentError -> key
    end
  end

  defp key_to_atom_safe(key), do: key

  defp format_cell_date(cell_value, locale) when is_binary(cell_value) do
    format_cell_date_iso8601(cell_value, locale)
  end

  defp format_cell_date(cell_value, _locale), do: cell_value

  defp format_cell_date_iso8601(cell_value, locale) do
    case Date.from_iso8601(cell_value) do
      {:ok, date} -> format_date(date, locale)
      _ -> format_cell_date_datetime(cell_value, locale)
    end
  end

  defp format_cell_date_datetime(cell_value, locale) do
    case DateTime.from_iso8601(cell_value) do
      {:ok, datetime} -> format_datetime(datetime, locale)
      _ -> format_cell_date_naive(cell_value, locale)
    end
  end

  defp format_cell_date_naive(cell_value, locale) do
    case NaiveDateTime.from_iso8601(cell_value) do
      {:ok, naive_dt} ->
        datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
        format_datetime(datetime, locale)

      _ ->
        cell_value
    end
  end

  defp translate_membership_fee_status_in_rows(rows, columns) do
    status_col_index = find_membership_fee_status_index(columns)

    if status_col_index do
      translate_rows_status(rows, status_col_index)
    else
      rows
    end
  end

  defp find_membership_fee_status_index(columns) do
    Enum.find_index(columns, fn col ->
      col.kind == :computed && col.key == :membership_fee_status
    end)
  end

  defp translate_rows_status(rows, status_col_index) do
    Enum.map(rows, fn row ->
      List.update_at(row, status_col_index, &translate_membership_fee_status/1)
    end)
  end

  defp translate_membership_fee_status("paid"), do: gettext("Paid")
  defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid")
  defp translate_membership_fee_status("suspended"), do: gettext("Suspended")
  defp translate_membership_fee_status(value), do: value

  defp build_imprintor_config(template_content, template_data, root_directory) do
    # Imprintor.Config.new(source_document, inputs, options)
    # inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
    # options: set root_directory to temp dir to ensure no symlink issues
    # extra_fonts: list of font file paths for Typst to use
    extra_fonts = get_extra_fonts()
    options = [root_directory: root_directory, extra_fonts: extra_fonts]

    config = Imprintor.Config.new(template_content, template_data, options)
    {:ok, config}
  end

  defp get_extra_fonts do
    font_paths = get_font_paths()

    Enum.reduce_while(font_paths, [], &find_fonts_in_path/2)
    |> normalize_fonts_result()
  end

  defp get_font_paths do
    [
      Path.join(Application.app_dir(:mv, "priv"), "fonts"),
      Path.join([File.cwd!(), "priv", "fonts"])
    ]
  end

  defp find_fonts_in_path(base_path, _acc) do
    case File.ls(base_path) do
      {:ok, files} -> process_font_files(files, base_path)
      {:error, _reason} -> {:cont, []}
    end
  end

  defp process_font_files(files, base_path) do
    fonts =
      files
      |> Enum.filter(&String.ends_with?(&1, ".ttf"))
      |> Enum.map(&Path.join(base_path, &1))
      |> Enum.sort()

    if fonts != [] do
      {:halt, fonts}
    else
      {:cont, []}
    end
  end

  defp normalize_fonts_result([]), do: []
  defp normalize_fonts_result(fonts), do: fonts

  defp compile_to_pdf(config) do
    case Imprintor.compile_to_pdf(config) do
      {:ok, pdf_binary} when is_binary(pdf_binary) ->
        # Verify PDF magic bytes
        if String.starts_with?(pdf_binary, "%PDF") do
          {:ok, pdf_binary}
        else
          Logger.error(
            "PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
          )

          {:error, :invalid_pdf_format}
        end

      {:error, reason} ->
        Logger.error("PDF compilation failed",
          error: inspect(reason),
          error_type: :imprintor_compile_error
        )

        {:error, {:compile_error, reason}}

      other ->
        Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
          error_type: :unexpected_result
        )

        {:error, {:unexpected_result, other}}
    end
  rescue
    e ->
      Logger.error("PDF compilation raised exception: #{inspect(e)}",
        error_type: :compile_exception
      )

      {:error, {:compile_exception, e}}
  end
end
