defmodule Blogex.Blog do
  @moduledoc """
  Macro to define a blog context backed by NimblePublisher.

  ## Usage

  In your host application, define one module per blog:

      defmodule Firehose.EngineeringBlog do
        use Blogex.Blog,
          blog_id: :engineering,
          app: :firehose,
          from: "priv/blog/engineering/**/*.md",
          title: "Engineering Blog",
          description: "Deep dives into our tech stack",
          base_path: "/blog/engineering"
      end

      defmodule Firehose.ReleaseNotes do
        use Blogex.Blog,
          blog_id: :release_notes,
          app: :firehose,
          from: "priv/blog/release-notes/**/*.md",
          title: "Release Notes",
          description: "What's new in Firehose",
          base_path: "/blog/releases"
      end

  Each module compiles all markdown posts at build time and exposes
  query functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1`, etc.
  """

  defmacro __using__(opts) do
    blog_id = Keyword.fetch!(opts, :blog_id)
    app = Keyword.fetch!(opts, :app)
    from = Keyword.fetch!(opts, :from)
    title = Keyword.fetch!(opts, :title)
    description = Keyword.get(opts, :description, "")
    base_path = Keyword.fetch!(opts, :base_path)
    highlighters = Keyword.get(opts, :highlighters, [:makeup_elixir, :makeup_erlang])

    quote do
      alias Blogex.Post

      use NimblePublisher,
        build: Post,
        from: Application.app_dir(unquote(app), unquote(from)),
        as: :posts,
        highlighters: unquote(highlighters)

      # Inject the blog_id into each post and sort by descending date
      @posts @posts
             |> Enum.map(&Map.put(&1, :blog, unquote(blog_id)))
             |> Enum.sort_by(& &1.date, {:desc, Date})

      # Validate internal link semantics at compile time via before_compile
      @before_compile Blogex.Blog

      # Collect all unique tags
      @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()

      @blog_id unquote(blog_id)
      @blog_title unquote(title)
      @blog_description unquote(description)
      @blog_base_path unquote(base_path)

      @doc "Returns the blog identifier atom."
      def blog_id, do: @blog_id

      @doc "Returns the blog title."
      def title, do: @blog_title

      @doc "Returns the blog description."
      def description, do: @blog_description

      @doc "Returns the base URL path for this blog."
      def base_path, do: @blog_base_path

      @doc "Returns all compiled posts regardless of published status or date."
      def unfiltered_posts, do: @posts

      @doc "Returns all visible posts, newest first. Drafts are included in dev/test."
      def all_posts do
        today = Date.utc_today()

        if Blogex.show_drafts?() do
          Enum.filter(@posts, &(not Date.after?(&1.date, today)))
        else
          Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today)))
        end
      end

      @doc "Returns the N most recent published posts."
      def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)

      @doc "Returns all unique tags across all published posts."
      def all_tags do
        all_posts()
        |> Enum.flat_map(& &1.tags)
        |> Enum.uniq()
        |> Enum.sort()
      end

      @doc "Returns all published posts matching the given tag."
      def posts_by_tag(tag) do
        Enum.filter(all_posts(), fn post -> tag in post.tags end)
      end

      @doc "Returns a single post by slug/id, or raises."
      def get_post!(id) do
        Enum.find(unfiltered_posts(), &(&1.id == id)) ||
          raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}"
      end

      @doc "Returns a single post by slug/id, or nil."
      def get_post(id) do
        Enum.find(unfiltered_posts(), &(&1.id == id))
      end

      @doc "Returns paginated posts. Page is 1-indexed."
      def paginate(page \\ 1, per_page \\ 10) do
        posts = all_posts()
        total = length(posts)
        total_pages = max(ceil(total / per_page), 1)

        entries =
          posts
          |> Enum.drop((page - 1) * per_page)
          |> Enum.take(per_page)

        %{
          entries: entries,
          page: page,
          per_page: per_page,
          total_entries: total,
          total_pages: total_pages
        }
      end
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    blog_id = Module.get_attribute(env.module, :blog_id)

    quote do
      unquote(__MODULE__)._validate_links(@posts, unquote(blog_id))
    end
  end

  @doc false
  @spec _validate_links([Blogex.Post.t()], atom()) :: :ok
  def _validate_links(posts, blog_id) do
    Enum.each(posts, fn post ->
      case Blogex.LinkValidator.validate_body(post.body, blog_id, post_id: post.id) do
        :ok ->
          :ok

        {:error, errors} ->
          raise Blogex.LinkError,
            blog: blog_id,
            post_id: post.id,
            errors: errors
      end
    end)
  end
end
