defmodule Mix.Tasks.Compile.Thrift do
  use Mix.Task.Compiler
  alias Mix.Task.Compiler.Diagnostic
  alias Thrift.Parser.FileGroup

  @recursive true
  @manifest ".compile.thrift"
  @manifest_vsn :v1

  @moduledoc """
  Generates Elixir source files from Thrift IDL files

  When this task runs, it first checks the modification times of all source
  files that were generated by the set of `.thrift` files.  If any generated
  files are older than the `.thrift` file that generated them, they won't be
  regenerated.

  ## Command line options

    * `--force` - forces compilation regardless of modification times
    * `--verbose` - enable verbose compile task logging

  ## Configuration

    * `:files` - list of `.thrift` IDL files to compile
    * `:include_paths` - list of additional directory paths in which to
      search for included files
    * `:namespace` - default namespace for generated modules, which will
      be used when a Thrift file doesn't define its own `elixir` namespace.
      This value may be given as a string or atom. `nil` indicates that the
      files should be generated in the `:output_path` root. Defaults to
      `"Thrift.Generated"`.
    * `:output_path` - output directory into which the generated Elixir
      source files will be generated. Defaults to `"lib"`.

  These should be set in you project config in the `:thrift` key as in the
  example below.

  ```
  # example mix.exs
  defmodule MyProject.Mixfile do
    # ...

    def project do
      [
        # other settings...
        thrift: [
          files: Path.wildcard("thrift/**/*.thrift"),
          output_path: "lib/generated"
        ]
      ]
    end
  end
  ```
  """

  @switches [force: :boolean, verbose: :boolean]

  @impl true
  def run(args) do
    {opts, _, _} = OptionParser.parse(args, switches: @switches)

    config = Keyword.get(Mix.Project.config(), :thrift, [])
    input_files = Keyword.get(config, :files, [])
    output_path = Keyword.get(config, :output_path, "lib")

    parser_opts =
      config
      |> Keyword.take([:include_paths, :namespace])
      |> Keyword.put_new(:namespace, "Thrift.Generated")

    {_, diagnostics} =
      result =
      case parse(input_files, parser_opts) do
        {[], []} ->
          {:noop, []}

        {groups, [] = _diagnostics} ->
          groups
          |> extract_targets(output_path, opts[:force])
          |> generate(manifest(), output_path, opts)

        {_groups, diagnostics} ->
          {:error, diagnostics}
      end

    Enum.each(diagnostics, &print_diagnostic/1)
    result
  end

  @impl true
  def manifests, do: [manifest()]
  defp manifest, do: Path.join(Mix.Project.manifest_path(), @manifest)

  @impl true
  def clean, do: clean(manifest())

  defp clean(manifest) do
    Enum.each(read_manifest(manifest), &File.rm/1)
    File.rm(manifest)
  end

  @spec parse([Path.t()], Thrift.Parser.opts()) :: {[FileGroup.t()], [Diagnostic.t()]}
  defp parse(files, opts) do
    files
    |> Enum.reverse()
    |> Enum.reduce({[], []}, fn file, {groups, diagnostics} ->
      case Thrift.Parser.parse_file_group(file, opts) do
        {:ok, group} ->
          {[group | groups], diagnostics}

        {:error, errors} ->
          {groups, Enum.map(errors, &diagnostic/1) ++ diagnostics}
      end
    end)
  end

  @typep mappings :: [{:stale, FileGroup.t(), [Path.t()]} | {:ok, FileGroup.t(), [Path.t()]}]

  @spec extract_targets([FileGroup.t()], Path.t(), boolean) :: mappings
  defp extract_targets(groups, output_path, force) when is_list(groups) do
    for %FileGroup{initial_file: file} = group <- groups do
      targets =
        group
        |> Thrift.Generator.targets()
        |> Enum.map(&Path.join(output_path, &1))

      if force || Mix.Utils.stale?([file], targets) do
        {:stale, group, targets}
      else
        {:ok, group, targets}
      end
    end
  end

  @spec generate(mappings, Path.t(), Path.t(), OptionParser.parsed()) ::
          {:ok | :noop | :error, [Diagnostic.t()]}
  defp generate(mappings, manifest, output_path, opts) do
    timestamp = :calendar.universal_time()
    verbose = opts[:verbose]

    # Load the list of previously-generated files.
    previous = read_manifest(manifest)

    # Determine which of our current targets are in need of (re)generation.
    stale = for {:stale, group, targets} <- mappings, do: {group, targets}

    # Determine if there are any files that appear in our existing manifest
    # that are no longer relevant based on our current target mappings.
    removed =
      Enum.filter(previous, fn file ->
        not Enum.any?(mappings, fn {_, _, targets} -> file in targets end)
      end)

    if stale == [] && removed == [] do
      {:noop, []}
    else
      # Ensure we have an output directory and remove old target files.
      File.mkdir_p!(output_path)
      Enum.each(removed, &File.rm/1)

      unless Enum.empty?(stale) do
        Mix.Utils.compiling_n(length(stale), :thrift)

        Enum.each(stale, fn {group, _targets} ->
          Thrift.Generator.generate!(group, output_path)
          verbose && Mix.shell().info("Compiled #{group.initial_file}")
        end)
      end

      # Update and rewrite the manifest.
      entries = (previous -- removed) ++ Enum.flat_map(stale, &elem(&1, 1))
      write_manifest(manifest, :lists.usort(entries), timestamp)
      {:ok, []}
    end
  end

  defp diagnostic({file, line, message}, severity \\ :error) do
    %Diagnostic{
      file: file,
      position: line,
      message: message,
      severity: severity,
      compiler_name: "Thrift"
    }
  end

  defp print_diagnostic(%Diagnostic{file: file, position: pos, message: msg, severity: severity}) do
    color = if severity == :error, do: IO.ANSI.light_red(), else: IO.ANSI.yellow()
    Mix.shell().error("#{color}#{severity}: #{IO.ANSI.reset()}#{msg}\n  #{file}:#{pos}\n\n")
  end

  defp package_vsn do
    Keyword.get(Mix.Project.config(), :version)
  end

  @spec read_manifest(Path.t()) :: [Path.t()]
  defp read_manifest(manifest) do
    header = {@manifest_vsn, package_vsn()}

    try do
      manifest
      |> File.read!()
      |> :erlang.binary_to_term()
    rescue
      _ -> []
    else
      [^header | paths] -> paths
      _ -> []
    end
  end

  @spec write_manifest(Path.t(), [Path.t()], :calendar.datetime()) :: :ok
  defp write_manifest(manifest, paths, timestamp) do
    data =
      :erlang.term_to_binary(
        [{@manifest_vsn, package_vsn()} | paths],
        compressed: 9
      )

    File.mkdir_p!(Path.dirname(manifest))
    File.write!(manifest, data)
    File.touch!(manifest, timestamp)
  end
end
