lib/mix/tasks/install.ex

defmodule Mix.Tasks.Lvn.Install do
  @moduledoc "Installer Mix task for LiveView Native: `mix lvn.install`"
  use Mix.Task

  @requirements ["app.config"]

  @shortdoc "Installs LiveView Native."
  def run(args) do
    {parsed_args, _, _} = OptionParser.parse(args, strict: [namespace: :string])

    # Get all Mix tasks for LiveView Native client libraries
    valid_mix_tasks = get_installer_mix_tasks()
    host_project_config = get_host_project_config(parsed_args)

    run_all_install_tasks(valid_mix_tasks, host_project_config)
    native_config = merge_native_config(valid_mix_tasks)
    generate_native_exs_if_needed(host_project_config, native_config)
    update_config_exs_if_needed(host_project_config)
    clean_build_path(host_project_config)
    format_config_files()

    IO.puts("\nYour Phoenix app is ready to use LiveView Native!\n")
    IO.puts("Platform-specific project files have been placed in the \"native\" directory\n")

    :ok
  end

  ###

  defp run_all_install_tasks(mix_tasks, host_project_config) do
    mix_tasks
    |> Enum.map(&prompt_task_settings/1)
    |> Enum.map(&run_install_task(&1, host_project_config))
  end

  defp prompt_task_settings(%{client_name: client_name, prompts: [_ | _] = prompts} = task) do
    prompts
    |> Enum.reduce_while({:ok, task}, fn {prompt_key, prompt_settings}, {:ok, acc} ->
      case prompt_task_setting(prompt_settings, client_name) do
        {:error, message} ->
          Owl.IO.puts([Owl.Data.tag("#{client_name}: #{message}", :yellow)])

          {:halt, {:error, acc}}

        result ->
          settings = Map.get(acc, :settings, %{})
          updated_settings = Map.put(settings, prompt_key, result)

          {:cont, {:ok, Map.put(acc, :settings, updated_settings)}}
      end
    end)
  end

  defp prompt_task_setting(%{ignore: true}, _client_name), do: true

  defp prompt_task_setting(%{type: :confirm, label: label} = task, client_name) do
    if Owl.IO.confirm(message: "#{client_name}: #{label}", default: true) do
      if is_function(task[:on_yes]), do: apply(task[:on_yes], [])
    else
      if is_function(task[:on_no]), do: apply(task[:on_no], [])
    end
  end

  defp prompt_task_setting(%{type: :multiselect, label: label, options: options, default: default} = task, client_name) do
    default_label = Map.get(task, :default_label, inspect(default))

    case Owl.IO.multiselect(options, label: "#{client_name}: #{label} (Space-delimited, leave blank for default: #{default_label})") do
      [] ->
        default || []

      result ->
        result
    end
  end

  defp prompt_task_setting(_task, _client_name), do: nil

  defp run_install_task(result, host_project_config) do
    case result do
      {:ok, %{client_name: client_name, mix_task: mix_task, settings: settings}} ->
        Owl.IO.puts([Owl.Data.tag("* generating ", :green), "#{client_name} project files"])

        mix_task.run(["--host-project-config", host_project_config, "--task-settings", settings])

      _ ->
        :skipped
    end
  end

  defp get_installer_mix_tasks do
    Mix.Task.load_all()
    |> Enum.filter(&function_exported?(&1, :lvn_install_config, 0))
    |> Enum.map(fn module ->
      module
      |> apply(:lvn_install_config, [])
      |> Map.put(:mix_task, module)
    end)
  end

  defp get_host_project_config(parsed_args) do
    # Define some paths for the host project
    current_path = File.cwd!()
    mix_config_path = Path.join(current_path, "mix.exs")
    build_path = Path.join(current_path, "_build")

    # Ask the user some questiosn about the native project configuration
    preferred_route = prompt_config_option("What path should native clients connect to by default?", "/")
    preferred_prod_url = prompt_config_option("What URL will you use in production?", "example.com")
    preferred_route = if String.starts_with?(preferred_route, "/"), do: preferred_route, else: "/#{preferred_route}"

    %{
      app_config_path: Path.join(current_path, "/config/config.exs"),
      app_namespace: parsed_args[:namespace] || infer_app_namespace(mix_config_path),
      build_path: build_path,
      current_path: current_path,
      libs_path: Path.join(build_path, "dev/lib"),
      mix_config_path: mix_config_path,
      native_path: Path.join(current_path, "native"),
      preferred_prod_url: preferred_prod_url,
      preferred_route: preferred_route
    }
  end

  defp prompt_config_option(prompt_message, default_value) do
    "#{prompt_message} (Leave blank for default: \"#{default_value}\")\n"
    |> IO.gets()
    |> String.trim()
    |> default_if_blank(default_value)
  end

  defp default_if_blank(value, default_value) do
    if value == "", do: default_value, else: value
  end

  def infer_app_namespace(config_path) do
    with {:ok, config} <- File.read(config_path),
         {:ok, mix_project_ast} <- Code.string_to_quoted(config),
         {:ok, namespace} <- find_mix_project_namespace(mix_project_ast) do
      "#{namespace}"
    else
      _ ->
        raise "Could not infer Mix project namespace from mix.exs. Please provide it manually using the --namespace argument."
    end
  end

  defp find_mix_project_namespace(ast) do
    case ast do
      ast when is_list(ast) ->
        ast
        |> Enum.reduce_while({:error, :cannot_infer_app_name}, fn node, _acc ->
          {status, result} = find_mix_project_namespace(node)
          acc_op = if status == :ok, do: :halt, else: :cont

          {acc_op, {status, result}}
        end)

      {:defmodule, _, [{:__aliases__, _, [namespace, :MixProject]} | _rest]} ->
        {:ok, namespace}

      {:__block__, _, contents} ->
        find_mix_project_namespace(contents)

      _ ->
        {:error, :cannot_infer_app_name}
    end
  end

  defp merge_native_config(mix_tasks) do
    mix_tasks
    |> Enum.reduce(%{}, fn %{mix_config: mix_config}, acc ->
      DeepMerge.deep_merge(acc, mix_config)
    end)
  end

  defp generate_native_exs_if_needed(%{current_path: current_path}, %{} = native_config) do
    native_config_path = Path.join(current_path, "/config/native.exs")
    native_config_already_exists? = File.exists?(native_config_path)
    generate_native_config? = if native_config_already_exists?, do: Owl.IO.confirm(message: "native.exs already exists, regenerate it?", default: false), else: true

    if generate_native_config? do
      Owl.IO.puts([Owl.Data.tag("* creating ", :green), "config/native.exs"])
      lvn_configuration = native_exs_body(native_config)
      File.write(native_config_path, lvn_configuration)

      :ok
    else
      IO.puts("native.exs already exists, skipping...")
    end
  end

  defp native_exs_body(%{} = native_config) do
    config_body =
      native_config
      |> Enum.map(fn {key, config} ->
        config_value = inspect(config)
        config_value_formatted = String.slice(config_value, 1, String.length(config_value) - 2)

        "config :#{key}, #{config_value_formatted}"
      end)
      |> Enum.join("\n\n")

    """
    # This file is responsible for configuring LiveView Native.
    # It is auto-generated when running `mix lvn.install`.
    import Config

    #{config_body}
    """
  end

  defp update_config_exs_if_needed(%{app_config_path: app_config_path}) do
    # Update project's config.exs to import native.exs if needed.
    import_string = "import_config \"native.exs\""
    full_import_string = Enum.join(["\n", "# Import LiveView Native configuration", import_string], "\n")
    {:ok, app_config_body} = File.read(app_config_path)

    if String.contains?(app_config_body, import_string) do
      IO.puts("config.exs already imports native.exs, skipping...")
    else
      Owl.IO.puts([Owl.Data.tag("* updating ", :yellow), "config/config.exs"])

      {:ok, app_config} = File.open(app_config_path, [:write])
      updated_app_config_body = app_config_body <> "\n" <> full_import_string

      IO.binwrite(app_config, updated_app_config_body)
      File.close(app_config)
    end
  end

  defp format_config_files do
    System.cmd("mix", ["format", "*.exs"], cd: "config")
  end

  defp clean_build_path(%{build_path: build_path}) do
    # Clear _build path to ensure it's rebuilt with new Config
    File.rm_rf(build_path)
  end
end