lib/pardall_markdown/cache.ex

defmodule PardallMarkdown.Cache do
  require Logger
  alias PardallMarkdown.Content.{Post, Link}
  import PardallMarkdown.Content.Filters
  import PardallMarkdown.Content.Utils

  @cache_name Application.get_env(:pardall_markdown, PardallMarkdown.Content)[:cache_name]
  @index_cache_name Application.get_env(:pardall_markdown, PardallMarkdown.Content)[
                      :index_cache_name
                    ]

  def get_by_slug(slug), do: ConCache.get(@cache_name, slug_key(slug))

  def get_all_posts(type \\ :all) do
    ConCache.ets(@cache_name)
    |> :ets.tab2list()
    |> Enum.filter(fn
      {_, %Post{type: p_type}} -> type == :all or p_type == type
      _ -> false
    end)
    |> Enum.map(fn {_, %Post{} = post} -> post end)
  end

  def get_all_links(type \\ :all) do
    ConCache.ets(@cache_name)
    |> :ets.tab2list()
    |> Enum.filter(fn
      {_, %Link{type: l_type}} -> type == :all or l_type == type
      _ -> false
    end)
    |> Enum.map(fn {_, %Link{} = link} -> link end)
  end

  def get_all_links_indexed_by_slug(type \\ :all) do
    get_all_links(type)
    |> Enum.reduce(%{}, fn %Link{slug: slug} = link, acc ->
      Map.put(acc, slug, link)
    end)
  end

  def get_taxonomy_tree do
    get = fn -> ConCache.get(@index_cache_name, taxonomy_tree_key()) end

    case get.() do
      nil ->
        build_taxonomy_tree()
        get.()

      tree ->
        tree
    end
  end

  def get_content_tree(slug \\ "/") do
    get = fn -> ConCache.get(@index_cache_name, content_tree_key(slug)) end

    case get.() do
      nil ->
        build_content_tree()
        get.()

      tree ->
        tree
    end
  end

  def save_post(%Post{type: :index} = post) do
    save_post_taxonomies(post)
    post
  end

  def save_post(%Post{} = post) do
    save_post_pure(post)
    save_post_taxonomies(post)
    post
  end

  def save_post_pure(%Post{slug: _slug} = post) do
    :ok = save_slug(post)
    post
  end

  def update_post_field(slug, field, value) do
    case get_by_slug(slug) do
      nil -> nil
      %Post{} = post -> post |> Map.put(field, value) |> save_post_pure()
    end
  end

  def save_slug(%{slug: slug} = item) do
    key = slug_key(slug)
    ConCache.put(@cache_name, key, item)
  end

  def build_taxonomy_tree() do
    tree = do_build_taxonomy_tree()
    ConCache.put(@index_cache_name, taxonomy_tree_key(), tree)
    tree
  end

  @doc """
  Posts are extracted from the `children` field of a taxonomy
  and added to the taxonomies list as a Link, while keeping
  the correct nesting under their parent taxonomy.

  Posts are also sorted accordingly to their topmost taxonomy
  sorting configuration.
  """
  def build_content_tree do
    tree =
      do_build_taxonomy_tree(true)
      |> do_build_content_tree()

    # Embed each post `%Link{}` into their individual `%Post{}` entities

    Enum.each(tree, fn
      %Link{type: :post, slug: slug} = link ->
        update_post_field(slug, :link, link)

      _ ->
        :ignore
    end)

    ConCache.put(@index_cache_name, content_tree_key(), tree)

    # Split each root slug and its nested children and

    # save the roots independently.

    tree
    |> Enum.reduce(%{}, fn
      # Only root taxonomies, and ignore pages

      # (pages are posts in the root, but a link of type :post)

      %{type: :taxonomy, slug: slug, level: 0} = link, acc when slug != "/" ->
        Map.put(acc, slug, %{link: link, children: []})

      %{parents: [_ | [root_parent | _]]} = link, acc ->
        children = Map.get(acc, root_parent)[:children]
        put_in(acc[root_parent][:children], children ++ [link])

      # Ignore the very root link "/" and pages

      _, acc ->
        acc
    end)
    |> Enum.each(fn {root_slug, %{children: children}} ->
      ConCache.put(@index_cache_name, content_tree_key(root_slug), children)
    end)

    tree
  end

  def delete_slug(slug) do
    ConCache.delete(@cache_name, slug_key(slug))
  end

  def delete_all do
    ConCache.ets(@cache_name)
    |> :ets.delete_all_objects()

    ConCache.ets(@index_cache_name)
    |> :ets.delete_all_objects()
  end

  #

  # Internal

  #


  defp save_post_taxonomies(%Post{type: :index, taxonomies: taxonomies} = post) do
    taxonomies
    |> List.last()
    |> upsert_taxonomy_appending_post(post)
  end

  defp save_post_taxonomies(%Post{taxonomies: taxonomies} = post) do
    taxonomies
    |> Enum.map(&upsert_taxonomy_appending_post(&1, post))
  end

  defp upsert_taxonomy_appending_post(
         %Link{slug: slug} = taxonomy,
         %Post{type: :index, position: position, title: post_title, metadata: metadata} = post
       ) do
    do_update = fn taxonomy ->
      {:ok,
       %{
         taxonomy
         | index_post: post,
           position: position,
           title: post_title,
           sort_by: Map.get(metadata, :sort_by, default_sort_by()) |> maybe_to_atom(),
           sort_order: Map.get(metadata, :sort_order, default_sort_order()) |> maybe_to_atom()
       }}
    end

    ConCache.update(@cache_name, slug_key(slug), fn
      nil ->
        do_update.(taxonomy)

      %Link{} = taxonomy ->
        do_update.(taxonomy)
    end)
  end

  defp upsert_taxonomy_appending_post(
         %Link{slug: slug, children: children} = taxonomy,
         %Post{} = post
       ) do
    do_update = fn taxonomy, children ->
      stripped_post = post |> Map.put(:content, nil) |> Map.put(:toc, [])
      {:ok, %{taxonomy | children: children ++ [stripped_post]}}
    end

    ConCache.update(@cache_name, slug_key(slug), fn
      nil ->
        do_update.(taxonomy, children)

      %Link{children: children} = taxonomy ->
        do_update.(taxonomy, children)
    end)
  end

  defp get_taxonomy_sorting_methods_from_topmost_taxonomies do
    get_all_links()
    |> sort_by_slug()
    |> Enum.reduce(%{}, fn
      %Link{
        type: :taxonomy,
        level: 0,
        sort_by: sort_by,
        sort_order: sort_order,
        slug: slug
      },
      acc ->
        Map.put(acc, slug, {sort_by, sort_order})

      _, acc ->
        acc
    end)
  end

  defp do_build_taxonomy_tree(with_home \\ false) do
    sorting_methods = get_taxonomy_sorting_methods_from_topmost_taxonomies()

    get_all_links()
    |> sort_by_slug()
    |> (fn
          [%Link{slug: "/"} | tree] when not with_home -> tree
          tree -> tree
        end).()
    |> Enum.map(fn %Link{children: posts, slug: slug} = taxonomy ->
      posts =
        posts
        |> Enum.filter(fn
          %Post{taxonomies: [_t | _] = post_taxonomies} ->
            # The last taxonomy of a post is its parent taxonomy.

            # I.e. a post in *Blog > Art > 3D* has 3 taxonomies:

            # Blog, Blog > Art and Blog > Art > 3D,

            # where its parent is the last one.

            List.last(post_taxonomies).slug == slug

          _ ->
            true
        end)
        |> filter_by_is_published()

      taxonomy
      |> Map.put(:children, posts)
    end)
    |> Enum.map(fn %Link{children: posts} = taxonomy ->
      {sort_by, sort_order} = find_sorting_method_for_taxonomy(taxonomy, sorting_methods)

      taxonomy
      |> Map.put(:children, posts |> sort_by_custom(sort_by, sort_order))
    end)
  end

  defp do_build_content_tree(tree) do
    with_home =
      Application.get_env(:pardall_markdown, PardallMarkdown.Content)[:content_tree_display_home]

    parent_for_level1_post = fn
      ["/"], %Link{slug: slug, parents: parents}
      when slug != "/" ->
        parents ++ [slug]

      _, %Link{parents: parents} ->
        parents
    end

    tree
    |> Enum.reduce([], fn %Link{children: posts} = taxonomy, all ->
      all
      |> Kernel.++([Map.put(taxonomy, :children, [])])
      |> Kernel.++(
        posts
        |> Enum.map(fn %{taxonomies: post_taxonomies} = post ->
          %{parents: last_taxonomy_parents} = List.last(post_taxonomies)

          %Link{
            slug: post.slug,
            title: post.title,
            level: level_for_joined_post(taxonomy.slug, taxonomy.level),
            parents: parent_for_level1_post.(last_taxonomy_parents, taxonomy),
            type: :post,
            position: post.position,
            children: [post]
          }
        end)
      )
    end)
    |> (fn
          [%Link{slug: "/"} | tree] when not with_home ->
            tree

          tree ->
            tree
        end).()
    |> sort_taxonomies_embedded_posts()
    |> build_tree_navigation()
    |> update_embedded_taxonomies()
  end

  defp build_tree_navigation(tree) do
    total = Enum.count(tree)

    indexed =
      tree
      |> Enum.with_index()

    indexed
    |> Enum.map(fn
      {%Link{} = link, 0} ->
        next =
          case find_next_post(indexed, 0) do
            {next, _} -> next
            _ -> nil
          end

        Map.put(link, :next, next)

      {%Link{} = link, pos} when pos == total - 1 ->
        previous =
          case find_previous_post(indexed, pos) do
            {previous, _} -> previous
            _ -> nil
          end

        link
        |> Map.put(:previous, previous)

      {%Link{} = link, pos} ->
        previous =
          case find_previous_post(indexed, pos) do
            {previous, _} -> previous
            _ -> nil
          end

        next =
          case find_next_post(indexed, pos) do
            {next, _} -> next
            _ -> nil
          end

        link
        |> Map.put(:previous, previous)
        |> Map.put(:next, next)
    end)
  end

  defp find_previous_post(all_indexed, before_index) do
    all_indexed
    |> Enum.reverse()
    |> Enum.find(fn
      {%Link{type: :post}, pos} when pos < before_index -> true
      {_, _} -> false
    end)
  end

  defp find_next_post(all_indexed, after_index) do
    all_indexed
    |> Enum.find(fn
      {%Link{type: :post}, pos} when pos > after_index -> true
      {_, _} -> false
    end)
  end

  defp level_for_joined_post(parent_slug, parent_level) when parent_slug == "/",
    do: parent_level

  defp level_for_joined_post(_, parent_level), do: parent_level + 1

  defp sort_taxonomies_embedded_posts(tree) do
    taxonomies = get_all_links(:taxonomy)
    sorting_methods = get_taxonomy_sorting_methods_from_topmost_taxonomies()

    taxonomies
    |> Enum.map(fn
      %Link{type: :taxonomy, children: posts} = taxonomy ->
        {sort_by, sort_order} = find_sorting_method_for_taxonomy(taxonomy, sorting_methods)

        taxonomy
        |> Map.put(:children, posts |> sort_by_custom(sort_by, sort_order))
        |> save_slug()
    end)

    tree
  end

  defp find_sorting_method_for_taxonomy(
         %Link{parents: parents, slug: slug, level: level},
         sorting_methods
       ) do
    target_sort_taxonomy =
      cond do
        level == 0 and slug != "/" -> slug
        level == 0 -> "/"
        true -> Enum.at(parents, 1)
      end

    sorting_methods[target_sort_taxonomy]
  end

  # Updates taxonomies that are embedded into all posts's `Post.taxonomies`,

  # since after the content tree is built, there may be taxonomies

  # that have overriden data with their related _index.md files.

  defp update_embedded_taxonomies(tree) do
    taxonomies = get_all_links_indexed_by_slug(:taxonomy)
    posts = get_all_posts()

    for post <- posts do
      do_update_post_embedded_taxonomies(post, taxonomies)
      |> save_post_pure()
    end

    for {_, %Link{children: children_posts} = link} <- taxonomies do
      updated =
        for post <- children_posts do
          do_update_post_embedded_taxonomies(post, taxonomies)
        end

      :ok = save_slug(%{link | children: updated})
    end

    tree
  end

  defp do_update_post_embedded_taxonomies(
         %Post{taxonomies: post_taxonomies} = post,
         taxonomies_by_slug
       ) do
    updated =
      Enum.map(post_taxonomies, fn %Link{slug: slug} = taxonomy ->
        %{taxonomy | title: taxonomies_by_slug[slug].title}
      end)

    %{post | taxonomies: updated}
  end

  defp slug_key(slug), do: {:slug, slug}
  defp taxonomy_tree_key(slug \\ "/"), do: {:taxonomy_tree, slug}

  defp content_tree_key(slug \\ "/"),
    do: {:content_tree, slug}
end