defmodule Forum.Forms do
  @moduledoc """
  Owns the parsed forms document and supervises one QuickBEAM runtime
  per element. `DynamicSupervisor` so the tree can change at runtime.

  State (parsed Floki tree) lives in the ETS table `:forum_forms`,
  owned by the supervisor process. Reads are direct ETS lookups — no
  GenServer round-trip — and the table dies cleanly with the supervisor.

  Public lifecycle:
    Forum.Forms.path/0         — path to the root forms.html
    Forum.Forms.reload/0       — re-read priv/forms.html and network forms
    Forum.Forms.set_html/1     — replace HTML directly and reconcile
    Forum.Forms.add_form/2     — start one runtime ad-hoc
    Forum.Forms.remove_form/2  — stop the runtime keyed by {class, id}
    Forum.Forms.reconcile/0    — diff stored elements vs running runtimes
    Forum.Forms.html/0         — rendered root HTML generated from the Floki tree
    Forum.Forms.html/1         — rendered network HTML generated from that network tree
    Forum.Forms.tree/0         — parsed Floki tree
    Forum.Forms.elements/0     — descriptor maps for <forms-> children
  """
  use DynamicSupervisor
  require Logger

  @table :forum_forms

  # ── boot ──────────────────────────────────────────────────────────

  def start_link(_opts) do
    DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  @impl true
  def init(:ok) do
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
    DynamicSupervisor.init(strategy: :one_for_one)
  end

  # ── reads ─────────────────────────────────────────────────────────

  def html do
    render_tree(root_tree())
  end

  def html(network_slug) when is_binary(network_slug) do
    network_slug
    |> network_tree()
    |> render_tree()
  end

  def tree do
    case :ets.lookup(@table, :tree) do
      [{:tree, tree}] -> tree
      [] -> []
    end
  end

  def root_tree do
    case :ets.lookup(@table, :root_tree) do
      [{:root_tree, tree}] -> tree
      [] -> []
    end
  end

  def network_tree(network_slug) do
    case :ets.lookup(@table, {:network_tree, network_slug}) do
      [{{:network_tree, ^network_slug}, tree}] -> tree
      [] -> root_tree()
    end
  end

  def elements do
    tree()
    |> Floki.find("forms- > *")
    |> Enum.map(&to_descriptor/1)
  end

  def path do
    Forum.Assets.path("forms.html")
  end

  def network_paths do
    "networks/*/index.html"
    |> Forum.Assets.paths()
    |> Enum.map(fn path -> {path |> Path.dirname() |> Path.basename(), path} end)
    |> Enum.sort()
  end

  # ── mutations ─────────────────────────────────────────────────────

  @doc "Re-read priv/forms.html and priv/networks/*/index.html, store them, reconcile runtimes."
  def reload do
    root_tree = path() |> File.read!() |> Floki.parse_fragment!()

    network_trees =
      Enum.map(network_paths(), fn {slug, path} ->
        {slug, path |> File.read!() |> Floki.parse_fragment!()}
      end)

    all_elements =
      [root_tree | Enum.map(network_trees, fn {_slug, tree} -> tree end)]
      |> Enum.flat_map(&Floki.find(&1, "forms- > *"))

    :ets.insert(@table, [
      {:tree, wrap_forms(all_elements)},
      {:root_tree, root_tree}
      | Enum.map(network_trees, fn {slug, tree} -> {{:network_tree, slug}, tree} end)
    ])

    reconcile()
  end

  @doc "Replace the canonical HTML with `raw` and reconcile runtimes."
  def set_html(raw) when is_binary(raw) do
    tree = Floki.parse_fragment!(raw)
    :ets.insert(@table, [{:tree, tree}, {:root_tree, tree}])
    reconcile()
  end

  @doc "Start one supervised form runtime under `{class_name, id}`."
  def add_form(class_name, data) when is_binary(class_name) and is_map(data) do
    DynamicSupervisor.start_child(__MODULE__, child_spec_for(class_name, data))
  end

  @doc "Stop the form runtime registered under `{class_name, id}`."
  def remove_form(class_name, id) do
    case Registry.lookup(Forum.ProcessRegistry, {:form, {class_name, id}}) do
      [{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
      [] -> {:error, :not_found}
    end
  end

  # ── reconciliation ────────────────────────────────────────────────

  @doc """
  Diffs stored elements against the currently-running form runtimes:
  stops runtimes for elements that disappeared, starts runtimes for
  elements that appeared. Same `{class, id}` keeps its existing
  runtime even if the element's data changed — call `remove_form/2 +
  add_form/2` (or set new HTML containing a different id) to force a
  refresh.
  """
  def reconcile do
    desired =
      Map.new(elements(), fn el ->
        class = pascal_case(el["tag"])
        data = Map.put_new(el["attrs"], "content", el["text"])
        id = Map.get(data, "id") || :erlang.unique_integer([:positive])
        {{class, id}, {class, data}}
      end)

    current_keys = MapSet.new(current_form_keys())
    desired_keys = MapSet.new(Map.keys(desired))

    for {class, id} <- MapSet.difference(current_keys, desired_keys) do
      remove_form(class, id)
    end

    for key <- MapSet.difference(desired_keys, current_keys) do
      {class, data} = Map.fetch!(desired, key)
      add_form(class, data)
    end

    Logger.info("Forum.Forms: reconciled, target=#{map_size(desired)} runtimes")
    :ok
  end

  defp current_form_keys do
    for {_id, pid, _type, _mods} <- DynamicSupervisor.which_children(__MODULE__),
        is_pid(pid),
        {:form, key} <- Registry.keys(Forum.ProcessRegistry, pid),
        do: key
  end

  # ── child spec ────────────────────────────────────────────────────

  defp child_spec_for(class_name, data) do
    id = Map.get(data, "id") || :erlang.unique_integer([:positive])
    name = {:via, Registry, {Forum.ProcessRegistry, {:form, {class_name, id}}}}

    {QuickBEAM,
     name: name,
     id: {class_name, id},
     define: %{"className" => class_name, "formData" => data},
     script: Forum.Assets.path("js/form_init.js")}
  end

  # ── helpers ───────────────────────────────────────────────────────

  defp wrap_forms(elements), do: [{"forms-", [], elements}]

  defp render_tree([]), do: ""

  defp render_tree(tree) do
    tree
    |> Floki.find("forms- > *")
    |> Enum.map(&to_descriptor/1)
    |> Enum.group_by(& &1["tag"])
    |> Enum.map_join("\n", fn {tag, descriptors} -> render_table(tag, descriptors) end)
  end

  defp render_table(tag, descriptors) do
    tag_name = String.trim_trailing(tag, "-")
    columns = columns_for(descriptors)
    header = Enum.map_join(columns, "\n", &render_header/1)
    rows = Enum.map_join(descriptors, "\n", &render_row(&1, columns))

    """
    <section class="form-table" data-form-tag="#{escape_attr(tag_name)}">
      <h2><code>&lt;#{escape_html(tag_name)}-&gt;</code></h2>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
    #{header}
            </tr>
          </thead>
          <tbody>
    #{rows}
          </tbody>
        </table>
      </div>
    </section>
    """
    |> String.trim()
  end

  defp render_header(column) do
    "          <th>#{escape_html(column)}</th>"
  end

  defp render_row(descriptor, columns) do
    attrs = descriptor["attrs"]

    cells =
      Enum.map_join(columns, "\n", fn column ->
        value = if column == "content", do: descriptor["text"], else: Map.get(attrs, column, "")
        "          <td>#{escape_html(value)}</td>"
      end)

    """
        <tr>
    #{cells}
        </tr>
    """
    |> String.trim_trailing()
  end

  defp columns_for(descriptors) do
    attr_columns =
      descriptors
      |> Enum.flat_map(fn descriptor -> Map.keys(descriptor["attrs"]) end)
      |> Enum.uniq()

    content_columns =
      if Enum.any?(descriptors, &(&1["text"] != "")), do: ["content"], else: []

    sort_columns(attr_columns) ++ content_columns
  end

  defp sort_columns(columns) do
    preferred = ["id", "network-id", "name", "title", "email", "key"]
    preferred_columns = Enum.filter(preferred, &(&1 in columns))
    other_columns = columns -- preferred

    preferred_columns ++ Enum.sort(other_columns)
  end

  defp to_descriptor({tag, attrs, content}) do
    text =
      content
      |> Enum.filter(&is_binary/1)
      |> Enum.join("")
      |> String.trim()

    %{"tag" => tag, "attrs" => Map.new(attrs), "text" => text}
  end

  defp escape_attr(value) do
    value
    |> to_string()
    |> Plug.HTML.html_escape_to_iodata()
    |> IO.iodata_to_binary()
  end

  defp escape_html(value) do
    value
    |> to_string()
    |> Plug.HTML.html_escape_to_iodata()
    |> IO.iodata_to_binary()
  end

  defp pascal_case(tag) do
    tag
    |> String.trim_trailing("-")
    |> String.split("-")
    |> Enum.reject(&(&1 == ""))
    |> Enum.map_join("", &String.capitalize/1)
  end
end
