defmodule Berrypod.Pages do
  @moduledoc """
  Context for database-driven page definitions.

  Every page is a flat list of blocks stored as JSON. This module provides
  the public API for reading, writing, and loading block data for pages.

  Lookup order: ETS cache -> DB -> defaults (system pages only).
  Custom pages return nil when not in DB.
  """

  alias Berrypod.Repo
  alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache}

  import Ecto.Query

  # ── Read ──────────────────────────────────────────────────────────

  @doc """
  Gets a page definition by slug.

  Checks the ETS cache first, falls back to DB, then to built-in defaults
  (for system pages only). Returns a map with page fields, or nil for
  unknown custom slugs.
  """
  def get_page(slug) do
    case PageCache.get(slug) do
      {:ok, page_data} -> page_data
      :miss -> get_page_uncached(slug)
    end
  rescue
    # ETS table might not exist yet during startup
    ArgumentError -> get_page_uncached(slug)
  end

  @doc """
  Gets a page from the DB. System slugs fall back to defaults; custom slugs
  return nil when not found. Used by PageCache.warm/0.
  """
  def get_page_from_db(slug) do
    case Repo.one(from p in Page, where: p.slug == ^slug) do
      %Page{} = page ->
        page_to_map(page)

      nil ->
        if Page.system_slug?(slug) do
          Defaults.for_slug(slug)
        else
          nil
        end
    end
  end

  @doc "Gets the raw Page struct by slug. Returns nil if not in DB."
  def get_page_struct(slug) do
    Repo.one(from p in Page, where: p.slug == ^slug)
  end

  defp get_page_uncached(slug) do
    page_data = get_page_from_db(slug)

    # Cache for next time (best-effort, ETS might not be up yet)
    try do
      PageCache.put(slug, page_data)
    rescue
      ArgumentError -> :ok
    end

    page_data
  end

  @doc "Lists all 14 system pages with their current definitions."
  def list_system_pages do
    Page.system_slugs()
    |> Enum.map(&get_page/1)
  end

  @doc false
  def list_pages, do: list_system_pages()

  @doc "Lists all custom pages, ordered by title."
  def list_custom_pages do
    from(p in Page, where: p.type == "custom", order_by: [asc: p.title])
    |> Repo.all()
    |> Enum.map(&page_to_map/1)
  end

  @doc "Lists all pages (system + custom)."
  def list_all_pages do
    list_system_pages() ++ list_custom_pages()
  end

  @doc "Lists pages that have a custom URL slug set. Used by Routes cache."
  def list_pages_with_custom_urls do
    from(p in Page, where: not is_nil(p.url_slug))
    |> Repo.all()
  end

  @doc "Gets a published page by its effective URL (url_slug or slug)."
  def get_published_page_by_effective_url(url) do
    # First check for custom url_slug match
    case Repo.one(from p in Page, where: p.url_slug == ^url and p.published == true) do
      nil ->
        # Fall back to slug match for custom pages
        Repo.one(
          from p in Page,
            where: p.slug == ^url and p.type == "custom" and p.published == true
        )

      page ->
        page
    end
  end

  # ── Write ─────────────────────────────────────────────────────────

  @doc """
  Saves a page's block list. Creates or updates the DB row and invalidates
  the cache. Uses the appropriate changeset based on page type.

  Returns `{:ok, page}` or `{:error, changeset}`.
  """
  def save_page(slug, attrs) do
    existing = Repo.one(from p in Page, where: p.slug == ^slug)

    changeset_fn =
      if Page.system_slug?(slug),
        do: &Page.system_changeset/2,
        else: &Page.custom_changeset/2

    result =
      case existing do
        nil ->
          %Page{}
          |> changeset_fn.(Map.merge(%{slug: slug}, attrs))
          |> Repo.insert()

        %Page{} = page ->
          page
          |> changeset_fn.(attrs)
          |> Repo.update()
      end

    case result do
      {:ok, page} ->
        PageCache.invalidate(slug)
        enqueue_link_check(slug)
        {:ok, page}

      error ->
        error
    end
  end

  @doc """
  Creates a new custom page.

  Returns `{:ok, page}` or `{:error, changeset}`.
  """
  def create_custom_page(attrs) do
    result =
      %Page{}
      |> Page.custom_changeset(attrs)
      |> Repo.insert()

    case result do
      {:ok, page} ->
        PageCache.put(page.slug, page_to_map(page))
        {:ok, page}

      error ->
        error
    end
  end

  @doc """
  Updates a custom page. Creates an auto redirect if the slug changes.

  Returns `{:ok, page}` or `{:error, changeset}`.
  """
  def update_custom_page(%Page{type: "custom"} = page, attrs) do
    old_slug = page.slug

    result =
      page
      |> Page.custom_changeset(attrs)
      |> Repo.update()

    case result do
      {:ok, updated} ->
        PageCache.invalidate(old_slug)
        PageCache.put(updated.slug, page_to_map(updated))

        if old_slug != updated.slug do
          Berrypod.Redirects.create_auto(%{
            from_path: "/#{old_slug}",
            to_path: "/#{updated.slug}",
            source: "auto_slug_change"
          })
        end

        enqueue_link_check(updated.slug)
        {:ok, updated}

      error ->
        error
    end
  end

  def update_custom_page(%Page{type: "system"}, _attrs), do: {:error, :system_page}

  @doc """
  Deletes a custom page.

  Returns `{:ok, page}` or `{:error, :system_page}`.
  """
  def delete_custom_page(%Page{type: "custom"} = page) do
    case Repo.delete(page) do
      {:ok, deleted} ->
        PageCache.invalidate(deleted.slug)
        {:ok, deleted}

      error ->
        error
    end
  end

  def delete_custom_page(%Page{type: "system"}), do: {:error, :system_page}

  @doc """
  Duplicates a custom page, creating a draft copy with a "-copy" slug suffix.
  """
  def duplicate_custom_page(%Page{type: "custom"} = page) do
    new_slug = find_available_slug(page.slug <> "-copy")

    create_custom_page(%{
      "slug" => new_slug,
      "title" => page.title <> " (copy)",
      "blocks" => page.blocks,
      "published" => false,
      "meta_description" => page.meta_description,
      "show_in_nav" => false
    })
  end

  defp find_available_slug(slug) do
    if Repo.one(from p in Page, where: p.slug == ^slug, select: p.id) do
      find_available_slug(slug <> "-2")
    else
      slug
    end
  end

  @doc """
  Resets a page to its default block list. Deletes the DB row (if any)
  and invalidates the cache.
  """
  def reset_page(slug) do
    case Repo.one(from p in Page, where: p.slug == ^slug) do
      nil -> :ok
      page -> Repo.delete(page)
    end

    PageCache.invalidate(slug)
    :ok
  end

  # ── Block data loading ────────────────────────────────────────────

  @doc """
  Runs data loaders for all blocks on a page, returning a merged map
  of extra assigns.

  Only blocks with a `data_loader` in the BlockTypes registry trigger
  a query. Blocks without data loaders rely on global or page-context assigns.
  """
  def load_block_data(blocks, assigns) do
    BlockTypes.load_data(blocks, assigns)
  end

  # ── Helpers ───────────────────────────────────────────────────────

  defp enqueue_link_check(slug) do
    Oban.insert(Berrypod.Workers.DeadLinkCheckerWorker.new(%{"page_slug" => slug}))
  end

  defp page_to_map(%Page{} = page) do
    %{
      slug: page.slug,
      title: page.title,
      blocks: page.blocks,
      type: page.type || "system",
      published: page.published,
      meta_description: page.meta_description,
      show_in_nav: page.show_in_nav,
      nav_label: page.nav_label,
      nav_position: page.nav_position,
      url_slug: page.url_slug
    }
  end

  # ── URL customisation ────────────────────────────────────────────

  @doc """
  Updates a page's custom URL slug. Creates a redirect from the old
  effective URL to the new one, and invalidates the R module cache.

  Pass nil or empty string to clear the custom URL.
  """
  def update_page_url_slug(%Page{} = page, url_slug) do
    old_effective_url = Page.effective_url(page)

    # Normalise empty string to nil
    url_slug = if url_slug == "", do: nil, else: url_slug

    changeset_fn =
      if Page.system_slug?(page.slug),
        do: &Page.system_changeset/2,
        else: &Page.custom_changeset/2

    result =
      page
      |> changeset_fn.(%{url_slug: url_slug})
      |> Repo.update()

    case result do
      {:ok, updated} ->
        new_effective_url = Page.effective_url(updated)

        # Create redirect if URL changed
        if old_effective_url != new_effective_url do
          # Delete any stale redirect FROM the new URL (it's now a live page)
          delete_stale_redirect("/#{new_effective_url}")

          # Create redirect from old URL to new URL
          Berrypod.Redirects.create_auto(%{
            from_path: "/#{old_effective_url}",
            to_path: "/#{new_effective_url}",
            source: "auto_slug_change"
          })
        end

        # Invalidate caches synchronously to avoid race conditions
        PageCache.invalidate(page.slug)
        BerrypodWeb.R.invalidate_all_sync()

        {:ok, updated}

      error ->
        error
    end
  end

  def update_page_url_slug(slug, url_slug) when is_binary(slug) do
    case get_page_struct(slug) do
      nil ->
        # Page doesn't exist in DB - create it from defaults if it's a system page
        if Page.system_slug?(slug) do
          defaults = Defaults.for_slug(slug)

          case save_page(slug, defaults) do
            {:ok, page} -> update_page_url_slug(page, url_slug)
            error -> error
          end
        else
          {:error, :not_found}
        end

      page ->
        update_page_url_slug(page, url_slug)
    end
  end

  # When a URL becomes a live page, delete any redirect from that URL
  defp delete_stale_redirect(path) do
    case Repo.one(from r in Berrypod.Redirects.Redirect, where: r.from_path == ^path) do
      %Berrypod.Redirects.Redirect{} = redirect ->
        Berrypod.Redirects.delete_redirect(redirect)

      nil ->
        :ok
    end
  end
end
