defmodule Cascade.Template do
  @moduledoc """
  The template generation logic.

  Every `Cascade` template must implement this behaviour. It provides
  callbacks which affect the template generation logic.
  """

  @doc """
  Returns the name of template.

  The names must be unique, it is advised to use a prefix in order to avoid
  collisions with other packages.
  """
  @callback name() :: atom()

  @doc """
  The absolute path to the template.
  """
  @callback assets_path() :: String.t()

  @doc """
  The CLI arguments (if any) schema of the template.

  It should return a valid `CliOptions` arguments schema. These CLI options will
  be automatically parsed and assigned to the template's bindings when invoked
  from the cascade mix task.

  ## Examples

  ```elixir
  defmodule MyTemplate do
    @behaviour Cascade.Template

    @impl true
    def args_schema do
      [
        path: [
          type: :string,
          doc: "The path to store the template to",
          required: true
        ],
        auth: [
          type: :boolean,
          doc: "Whether to generate authentication logic",
          default: false
        ]
      ]
    end
  end
  ```
  """
  @callback args_schema() :: keyword()

  @doc """
  Used for custom validation and post-processing of cli arguments.

  This can be used for performing custom validations/casting of cli arguments
  and optionally augment them. It expects the parsed cli arguments and is
  expected to return `{:ok, opts}` in case of success or `{:error, reason}`
  in case of failure.

  All templates are required to define the expected cli arguments under their
  `config.exs`. The callback will be called in order to validate the user provided
  arguments.

  ## Examples

  Assuming that you have a template that expects a `--path` argument. Since
  generator works with absolute paths you can override this callback in order
  to expand the path if it is relative.

  ```elixir
  def validate_cli_opts(opts) do
    path =
      Keyword.fetch!(opts, :path)
      |> Path.expand(File.cwd())

    opts = Keyword.put(opts, :path, path)

    {:ok, opts}
  end
  ```
  """
  @callback validate_cli_opts(opts :: keyword()) :: {:ok, keyword()} | {:error, String.t()}

  @doc """
  Returns the destination path for the given template file.

  It expects a file path, the template's config and the user provided CLI arguments and
  is expected to return the destination path for this specific asset. The `asset_path` is
  the template file's path with respect to the `assets`.

  This will be called for all template files. For example for the following `foo`
  template:

      templates/foo
      ├── README.md
      ├── lib
      │   └── foo.ex
      └── mix.exs

  it will be called for the following asset paths:

  - `README.md`
  - `mix.exs`
  - `lib/foo.ex`
  """
  @callback destination_path(
              asset_path :: String.t(),
              output_path :: String.t(),
              opts :: keyword()
            ) ::
              Path.t()

  @doc """
  Optional callback to be invoked before the template generation.

  You can use it to perform setup before the actual template invocation.
  """
  @callback pre_generate(output_path :: Path.t(), opts :: keyword()) :: :ok

  @doc """
  Optional callback to be invoked after the template generation has finished.

  You can use it to perform post-generation clean up, log a message to the user
  or perform any custom logic after the template generation completion (e.g. format
  the generated code).
  """
  @callback post_generate(output_path :: Path.t(), opts :: keyword()) :: :ok

  @optional_callbacks [pre_generate: 2, post_generate: 2]

  defmacro __using__(_opts) do
    quote do
      Module.register_attribute(__MODULE__, :shortdoc, persist: true)

      @behaviour Cascade.Template

      app = Application.get_application(__MODULE__)

      @impl Cascade.Template
      def args_schema, do: []

      @impl Cascade.Template
      def validate_cli_opts(opts), do: {:ok, opts}

      @impl Cascade.Template
      def destination_path(asset_path, output_path, _opts), do: Path.join(output_path, asset_path)

      defoverridable args_schema: 0,
                     validate_cli_opts: 1,
                     destination_path: 3
    end
  end

  @doc """
  Generates a template
  """
  @spec generate(template :: module(), output_path :: Path.t(), opts :: keyword()) :: :ok
  def generate(template, output_path, opts) do
    pre_generate(template, output_path, opts)

    output_path = Path.expand(output_path)

    for asset <- template_assets(template), not File.dir?(asset) do
      relative_asset_path = Path.relative_to(asset, template.assets_path())
      destination_path = template.destination_path(relative_asset_path, output_path, opts)

      body =
        asset
        |> EEx.eval_file(opts)
        |> maybe_format(destination_path)

      Mix.Generator.create_file(destination_path, body, force: true)
    end

    post_generate(template, output_path, opts)
  end

  defp template_assets(template) do
    assets_path = template.assets_path()
    assets = Path.wildcard(Path.join(assets_path, "**"), match_dot: true)

    case assets do
      [] ->
        raise ArgumentError,
              "no assets defined for template #{inspect(template.name())} under #{assets_path}"

      assets ->
        assets
    end
  end

  @elixir_extensions [".ex", ".exs", ".heex"]

  defp maybe_format(body, path) do
    extension = Path.extname(path)

    case extension in @elixir_extensions do
      true -> Code.format_string!(body)
      false -> body
    end
  end

  @doc """
  Gets the moduledoc for the given template `module`.

  Returns the moduledoc or `nil`.
  """
  @spec moduledoc(module :: module()) :: String.t() | false
  def moduledoc(module) when is_atom(module) do
    case Code.fetch_docs(module) do
      {:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} -> moduledoc
      _ -> false
    end
  end

  @doc """
  Gets the shortdoc for the given template `module`.

  Returns the shortdoc or `nil`.
  """
  @spec shortdoc(module :: module()) :: String.t() | nil
  def shortdoc(module) when is_atom(module) do
    case List.keyfind(module.__info__(:attributes), :shortdoc, 0) do
      {:shortdoc, [shortdoc]} -> shortdoc
      _ -> nil
    end
  end

  defp pre_generate(module, output_path, opts) do
    case function_exported?(module, :pre_generate, 2) do
      true -> module.pre_generate(output_path, opts)
      false -> :ok
    end
  end

  defp post_generate(module, output_path, opts) do
    case function_exported?(module, :post_generate, 2) do
      true -> module.post_generate(output_path, opts)
      false -> :ok
    end
  end
end
