defmodule Mix.Tasks.Phx.New do
  @moduledoc """
  Creates a new Phoenix project.

  It expects the path of the project as an argument.

      $ mix phx.new PATH [--module MODULE] [--app APP]

  A project at the given PATH will be created. The
  application name and module name will be retrieved
  from the path, unless `--module` or `--app` is given.

  ## Options

    * `--umbrella` - generate an umbrella project,
      with one application for your domain, and
      a second application for the web interface.

    * `--app` - the name of the OTP application

    * `--module` - the name of the base module in
      the generated skeleton

    * `--database` - specify the database adapter for Ecto. One of:

        * `postgres` - via https://github.com/elixir-ecto/postgrex
        * `mysql` - via https://github.com/elixir-ecto/myxql
        * `mssql` - via https://github.com/livehelpnow/tds
        * `sqlite3` - via https://github.com/elixir-sqlite/ecto_sqlite3

      Please check the driver docs for more information
      and requirements. Defaults to "postgres".

    * `--adapter` - specify the http adapter. One of:
        * `cowboy` - via https://github.com/elixir-plug/plug_cowboy
        * `bandit` - via https://github.com/mtrudel/bandit

      Please check the adapter docs for more information
      and requirements. Defaults to "bandit".

    * `--no-assets` - equivalent to `--no-esbuild` and `--no-tailwind`

    * `--no-dashboard` - do not include Phoenix.LiveDashboard

    * `--no-ecto` - do not generate Ecto files

    * `--no-esbuild` - do not include esbuild dependencies and assets.
      We do not recommend setting this option, unless for API only
      applications, as doing so requires you to manually add and
      track JavaScript dependencies

    * `--no-gettext` - do not generate gettext files

    * `--no-html` - do not generate HTML views

    * `--no-live` - comment out LiveView socket setup in your Endpoint
      and assets/js/app.js. Automatically disabled if --no-html is given

    * `--no-mailer` - do not generate Swoosh mailer files

    * `--no-tailwind` - do not include tailwind dependencies and assets.
      The generated markup will still include Tailwind CSS classes, those
      are left-in as reference for the subsequent styling of your layout
      and components

    * `--binary-id` - use `binary_id` as primary key type in Ecto schemas

    * `--verbose` - use verbose output

    * `-v`, `--version` - prints the Phoenix installer version

    * `--no-version-check` - skip the version check for the latest phx_new version

    * `--no-agents-md` - do not generate an `AGENTS.md` file

  When passing the `--no-ecto` flag, Phoenix generators such as
  `phx.gen.html`, `phx.gen.json`, `phx.gen.live`, and `phx.gen.context`
  may no longer work as expected as they generate context files that rely
  on Ecto for the database access. In those cases, you can pass the
  `--no-context` flag to generate most of the HTML and JSON files
  but skip the context, allowing you to fill in the blanks as desired.

  Similarly, if `--no-html` is given, the files generated by
  `phx.gen.html` will no longer work, as important HTML components
  will be missing.

  ## Installation

  `mix phx.new` by default prompts you to fetch and install your
  dependencies. You can enable this behaviour by passing the
  `--install` flag or disable it with the `--no-install` flag.

  ## Examples

      $ mix phx.new hello_world

  Is equivalent to:

      $ mix phx.new hello_world --module HelloWorld

  Or without the HTML and JS bits (useful for APIs):

      $ mix phx.new ~/Workspace/hello_world --no-html --no-assets

  As an umbrella:

      $ mix phx.new hello --umbrella

  Would generate the following directory structure and modules:

  ```text
  hello_umbrella/   Hello.Umbrella
    apps/
      hello/        Hello
      hello_web/    HelloWeb
  ```

  You can read more about umbrella projects using the
  official [Elixir guide](https://hexdocs.pm/elixir/dependencies-and-umbrella-projects.html#umbrella-projects)

  ## `PHX_NEW_CACHE_DIR`

  In rare cases, it may be useful to copy the build from a previously
  cached build. To do this, set the `PHX_NEW_CACHE_DIR` environment
  variable before running `mix phx.new`. For example, you could generate a
  cache by running:

  ```shell
  mix phx.new mycache --no-install && cd mycache \
    && mix deps.get && mix deps.compile && mix assets.setup \
    && rm -rf assets config lib priv test mix.exs README.md
  ```

  Your cached build directory should contain:

      _build
      deps
      mix.lock

  Then you could run:

  ```shell
  PHX_NEW_CACHE_DIR=/path/to/mycache mix phx.new myapp
  ```

  The entire cache directory will be copied to the new project, replacing
  any existing files where conflicts exist.
  """
  use Mix.Task
  alias Phx.New.{Generator, Project, Single, Umbrella, Web, Ecto}

  @version Mix.Project.config()[:version]
  @shortdoc "Creates a new Phoenix v#{@version} application"

  @switches [
    dev: :boolean,
    assets: :boolean,
    esbuild: :boolean,
    tailwind: :boolean,
    ecto: :boolean,
    app: :string,
    module: :string,
    web_module: :string,
    database: :string,
    binary_id: :boolean,
    html: :boolean,
    gettext: :boolean,
    umbrella: :boolean,
    verbose: :boolean,
    live: :boolean,
    dashboard: :boolean,
    install: :boolean,
    prefix: :string,
    mailer: :boolean,
    adapter: :string,
    inside_docker_env: :boolean,
    from_elixir_install: :boolean,
    version_check: :boolean,
    agents_md: :boolean
  ]

  @reserved_app_names ~w(server table)

  @impl true
  def run([version]) when version in ~w(-v --version) do
    Mix.shell().info("Phoenix installer v#{@version}")
  end

  def run(argv) do
    elixir_version_check!()

    {opts, argv} = OptionParser.parse!(argv, strict: @switches)

    version_task =
      if Keyword.get(opts, :version_check, true) do
        get_latest_version("phx_new")
      end

    result =
      case {opts, argv} do
        {_opts, []} ->
          Mix.Tasks.Help.run(["phx.new"])

        {opts, [base_path | _]} ->
          if opts[:umbrella] do
            generate(base_path, Umbrella, :project_path, opts)
          else
            generate(base_path, Single, :base_path, opts)
          end
      end

    if version_task do
      try do
        # if we get anything else than a `Version`, we'll get a MatchError
        # and fail silently
        %Version{} = latest_version = Task.await(version_task, 3_000)
        maybe_warn_outdated(latest_version)
      rescue
        _ -> :ok
      catch
        :exit, _ -> :ok
      end
    end

    result
  end

  @doc false
  def run(argv, generator, path) do
    elixir_version_check!()

    case OptionParser.parse!(argv, strict: @switches) do
      {_opts, []} -> Mix.Tasks.Help.run(["phx.new"])
      {opts, [base_path | _]} -> generate(base_path, generator, path, opts)
    end
  end

  defp generate(base_path, generator, path, opts) do
    base_path
    |> Project.new(opts)
    |> generator.prepare_project()
    |> Generator.put_binding()
    |> validate_project(path)
    |> generator.generate()
    |> maybe_copy_cached_build(path)
    |> maybe_init_git(path)
    |> maybe_prompt_to_install_deps(generator, path)
  end

  defp validate_project(%Project{opts: opts} = project, path) do
    check_app_name!(project.app, !!opts[:app])
    check_directory_existence!(Map.fetch!(project, path))
    check_module_name_validity!(project.root_mod)
    check_module_name_availability!(project.root_mod)

    project
  end

  defp maybe_prompt_to_install_deps(%Project{} = project, generator, path_key) do
    # we can skip the install deps setup, even with --install, because we already copied deps
    if project.cached_build_path do
      project
    else
      prompt_to_install_deps(project, generator, path_key)
    end
  end

  defp prompt_to_install_deps(%Project{} = project, generator, path_key) do
    path = Map.fetch!(project, path_key)

    install? =
      Keyword.get_lazy(project.opts, :install, fn ->
        Mix.shell().yes?("\nFetch and install dependencies?")
      end)

    cd_step = ["$ cd #{relative_app_path(path)}"]

    maybe_cd(path, fn ->
      mix_step = install_mix(project, install?)

      if mix_step == [] do
        builders = Keyword.fetch!(project.binding, :asset_builders)

        if builders != [] do
          Mix.shell().info([:green, "* running ", :reset, "mix assets.setup"])

          # First compile only builders so we can install in parallel
          # TODO: Once we require Erlang/OTP 28, jason may no longer be required
          cmd(project, "mix deps.compile jason #{Enum.join(builders, " ")}", log: false)
        end

        tasks =
          Enum.map(builders, fn builder ->
            cmd = "mix do loadpaths --no-compile --no-listeners + #{builder}.install"
            Task.async(fn -> cmd(project, cmd, log: false, cd: project.web_path) end)
          end)

        cmd(project, "mix deps.compile")

        Task.await_many(tasks, :infinity)
      end

      print_missing_steps(cd_step ++ mix_step)

      if Project.ecto?(project) do
        print_ecto_info(generator)
      end

      if path_key == :web_path do
        Mix.shell().info("""
        Your web app requires a PubSub server to be running.
        The PubSub server is typically defined in a `mix phx.new.ecto` app.
        If you don't plan to define an Ecto app, you must explicitly start
        the PubSub in your supervision tree as:

            {Phoenix.PubSub, name: #{inspect(project.app_mod)}.PubSub}
        """)
      end

      print_mix_info(generator)
    end)
  end

  defp maybe_cd(path, func), do: path && File.cd!(path, func)

  defp install_mix(project, install?) do
    if install? do
      cmd(project, "mix deps.get")
    else
      ["$ mix deps.get"]
    end
  end

  defp print_missing_steps(steps) do
    Mix.shell().info("""

    We are almost there! The following steps are missing:

        #{Enum.join(steps, "\n    ")}
    """)
  end

  defp print_ecto_info(Web), do: :ok

  defp print_ecto_info(_gen) do
    Mix.shell().info("""
    Then configure your database in config/dev.exs and run:

        $ mix ecto.create
    """)
  end

  defp print_mix_info(Ecto) do
    Mix.shell().info("""
    You can run your app inside IEx (Interactive Elixir) as:

        $ iex -S mix
    """)
  end

  defp print_mix_info(_gen) do
    Mix.shell().info("""
    Start your Phoenix app with:

        $ mix phx.server

    You can also run your app inside IEx (Interactive Elixir) as:

        $ iex -S mix phx.server
    """)
  end

  defp relative_app_path(path) do
    case Path.relative_to_cwd(path) do
      ^path -> Path.basename(path)
      rel -> rel
    end
  end

  ## Helpers

  defp cmd(%Project{} = project, cmd, opts \\ []) do
    {log?, opts} = Keyword.pop(opts, :log, true)

    if log? do
      Mix.shell().info([:green, "* running ", :reset, cmd])
    end

    case Mix.shell().cmd(cmd, opts ++ cmd_opts(project)) do
      0 -> []
      _ -> ["$ #{cmd}"]
    end
  end

  defp cmd_opts(%Project{} = project) do
    if Project.verbose?(project) do
      []
    else
      [quiet: true]
    end
  end

  defp check_app_name!(name, from_app_flag) do
    with :ok <- validate_not_reserved(name),
         :ok <- validate_app_name_format(name, from_app_flag) do
      :ok
    end
  end

  defp validate_not_reserved(name) when name in @reserved_app_names do
    Mix.raise("Application name cannot be #{inspect(name)} as it is reserved")
  end

  defp validate_not_reserved(_name), do: :ok

  defp validate_app_name_format(name, from_app_flag) do
    if name =~ ~r/^[a-z][a-z0-9_]*$/ do
      :ok
    else
      extra =
        if !from_app_flag do
          ". The application name is inferred from the path, if you'd like to " <>
            "explicitly name the application then use the `--app APP` option."
        else
          ""
        end

      Mix.raise(
        "Application name must start with a letter and have only lowercase " <>
          "letters, numbers and underscore, got: #{inspect(name)}" <> extra
      )
    end
  end

  defp check_module_name_validity!(name) do
    unless inspect(name) =~ Regex.recompile!(~r/^[A-Z]\w*(\.[A-Z]\w*)*$/) do
      Mix.raise(
        "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect(name)}"
      )
    end
  end

  defp check_module_name_availability!(name) do
    [name]
    |> Module.concat()
    |> Module.split()
    |> Enum.reduce([], fn name, acc ->
      mod = Module.concat([Elixir, name | acc])

      if Code.ensure_loaded?(mod) do
        Mix.raise("Module name #{inspect(mod)} is already taken, please choose another name")
      else
        [name | acc]
      end
    end)
  end

  defp check_directory_existence!(path) do
    if File.dir?(path) and
         not Mix.shell().yes?(
           "The directory #{path} already exists. Are you sure you want to continue?"
         ) do
      Mix.raise("Please select another directory for installation.")
    end
  end

  defp elixir_version_check! do
    unless Version.match?(System.version(), "~> 1.15") do
      Mix.raise(
        "Phoenix v#{@version} requires at least Elixir v1.15\n " <>
          "You have #{System.version()}. Please update accordingly"
      )
    end
  end

  defp git_available? do
    case System.find_executable("git") do
      nil -> false
      _path -> true
    end
  end

  defp inside_git_repo?(path) do
    case System.cmd("git", ["status"], cd: path, stderr_to_stdout: true) do
      {_output, 0} -> true
      _ -> false
    end
  rescue
    _ -> false
  end

  defp maybe_init_git(%Project{} = project, path_key) do
    project_path = Map.fetch!(project, path_key)

    if git_available?() and not inside_git_repo?(project_path) do
      Mix.shell().info([:green, "* initializing git repository", :reset])

      case System.cmd("git", ["init"], cd: project_path) do
        {_output, 0} ->
          :ok

        {output, _} ->
          Mix.shell().error("Failed to initialize git repository: #{output}")
      end
    end

    project
  end

  defp maybe_copy_cached_build(%Project{} = project, path_key) do
    project_path = Map.fetch!(project, path_key)

    case System.fetch_env("PHX_NEW_CACHE_DIR") do
      {:ok, cache_dir} ->
        copy_cached_build(%{project_path: project_path, cache_dir: cache_dir})
        %{project | cached_build_path: cache_dir}

      :error ->
        project
    end
  end

  defp copy_cached_build(%{project_path: project_path, cache_dir: cache_dir}) do
    if File.exists?(cache_dir) do
      Mix.shell().info("Copying cached build from #{cache_dir}")
      System.cmd("cp", ["-Rp", Path.join(cache_dir, "."), project_path])
    end
  end

  defp maybe_warn_outdated(latest_version) do
    if Version.compare(@version, latest_version) == :lt do
      Mix.shell().info([
        :yellow,
        "A new version of phx.new is available:",
        :green,
        " v#{latest_version}",
        :reset,
        ".",
        "\n",
        "You are currently running ",
        :red,
        "v#{@version}",
        :reset,
        ".\n",
        "To update, run:\n\n",
        "    $ mix local.phx\n"
      ])
    end
  end

  # we need to parse JSON, so we only check for new versions on Elixir 1.18+
  if Version.match?(System.version(), "~> 1.18") do
    defp get_latest_version(package) do
      Task.async(fn ->
        # ignore any errors to not prevent the generators from running
        # due to any issues while checking the version
        try do
          with {:ok, package} <- get_package(package) do
            versions =
              for release <- package["releases"],
                  version = Version.parse!(release["version"]),
                  # ignore pre-releases like release candidates, etc.
                  version.pre == [] do
                version
              end

            Enum.max(versions, Version)
          end
        rescue
          e -> {:error, e}
        catch
          :exit, _ -> {:error, :exit}
        end
      end)
    end

    defp get_package(name) do
      http_options =
        [
          ssl: [
            verify: :verify_peer,
            cacerts: :public_key.cacerts_get(),
            depth: 2,
            customize_hostname_check: [
              match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
            ],
            versions: [:"tlsv1.2", :"tlsv1.3"]
          ]
        ]

      options = [body_format: :binary]

      case :httpc.request(
             :get,
             {~c"https://hex.pm/api/packages/#{name}",
              [{~c"user-agent", ~c"Mix.Tasks.Phx.New/#{@version}"}]},
             http_options,
             options
           ) do
        {:ok, {{_, 200, _}, _headers, body}} ->
          {:ok, JSON.decode!(body)}

        {:ok, {{_, status, _}, _, _}} ->
          {:error, status}

        {:error, reason} ->
          {:error, reason}
      end
    end
  else
    defp get_latest_version(_), do: nil
  end
end
