lib/foundry/project_manager.ex

defmodule Foundry.ProjectManager do
  @moduledoc """
  Coordinates project selection, cloning, dependency installation, and runtime switching.
  """

  use GenServer

  @status_idle :idle
  @status_running :running
  @status_ready :ready
  @status_failed :failed
  @recent_limit 8

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def get_status do
    GenServer.call(__MODULE__, :get_status)
  catch
    :exit, _ -> default_status()
  end

  def active_project_root do
    GenServer.call(__MODULE__, :active_project_root)
  catch
    :exit, _ -> nil
  end

  def recent_projects do
    GenServer.call(__MODULE__, :recent_projects)
  catch
    :exit, _ -> []
  end

  def open_project(project_root) when is_binary(project_root) do
    GenServer.call(__MODULE__, {:open_project, project_root}, 30_000)
  end

  def clone_project(repo_url, parent_dir) when is_binary(repo_url) and is_binary(parent_dir) do
    GenServer.call(__MODULE__, {:clone_project, repo_url, parent_dir}, 30_000)
  end

  def reopen_last_project do
    GenServer.call(__MODULE__, :reopen_last_project, 30_000)
  end

  def current_project_root do
    active_project_root() || default_project_root()
  end

  def default_project_root do
    Application.get_env(
      :foundry_web,
      :default_project_root,
      Path.expand("../../reference_projects/igaming", __DIR__)
    )
  end

  def classify_folder(path) do
    normalized = path |> String.trim() |> Path.expand()
    has_mix = File.exists?(Path.join(normalized, "mix.exs"))
    has_foundry = File.dir?(Path.join(normalized, ".foundry"))

    cond do
      not File.dir?(normalized) -> {:error, :not_a_directory}
      has_mix and has_foundry -> :existing_project
      not has_mix and not has_foundry -> :empty_folder
      true -> :partial_project
    end
  end

  def new_project(folder_path, project_name) when is_binary(folder_path) and is_binary(project_name) do
    GenServer.call(__MODULE__, {:new_project, folder_path, project_name}, 120_000)
  end

  def init(_opts) do
    persisted = load_persisted_state()

    {:ok,
     %{
       active_project_root: nil,
       action_ref: nil,
       recent_projects: persisted.recent_projects,
       last_project_root: persisted.last_project_root,
       auto_reopen_attempted?: false,
       status: default_status()
     }}
  end

  def handle_call(:get_status, _from, state), do: {:reply, state.status, state}

  def handle_call(:active_project_root, _from, state),
    do: {:reply, state.active_project_root, state}

  def handle_call(:recent_projects, _from, state), do: {:reply, state.recent_projects, state}

  def handle_call(:reopen_last_project, _from, %{last_project_root: nil} = state) do
    {:reply, {:error, :no_last_project}, %{state | auto_reopen_attempted?: true}}
  end

  def handle_call(:reopen_last_project, _from, state) do
    reply_with_start({:open, state.last_project_root}, %{state | auto_reopen_attempted?: true})
  end

  def handle_call({:open_project, project_root}, _from, state) do
    reply_with_start({:open, project_root}, state)
  end

  def handle_call({:clone_project, repo_url, parent_dir}, _from, state) do
    reply_with_start({:clone, repo_url, parent_dir}, state)
  end

  def handle_call({:new_project, folder_path, project_name}, _from, state) do
    reply_with_start({:new_project, folder_path, project_name}, state)
  end

  def handle_info({:project_manager_log, action_ref, chunk}, %{action_ref: action_ref} = state) do
    {:noreply, update_in(state.status.logs, &append_log(&1, chunk))}
  end

  def handle_info(
        {:project_manager_complete, action_ref, {:ok, project_root, recent_entry}},
        %{action_ref: action_ref} = state
      ) do
    recent_projects =
      state.recent_projects
      |> prepend_recent(recent_entry)
      |> Enum.filter(&valid_recent_entry?/1)
      |> Enum.take(@recent_limit)

    persist_state(project_root, recent_projects)

    status =
      state.status
      |> Map.put(:state, @status_ready)
      |> Map.put(:step, "ready")
      |> Map.put(:message, "Project ready. Opening Foundry Studio...")
      |> Map.put(:project_root, project_root)
      |> Map.put(:last_error, nil)

    {:noreply,
     %{
       state
       | action_ref: nil,
         active_project_root: project_root,
         last_project_root: project_root,
         recent_projects: recent_projects,
         status: status
     }}
  end

  def handle_info(
        {:project_manager_complete, action_ref, {:error, reason}},
        %{action_ref: action_ref} = state
      ) do
    status =
      state.status
      |> Map.put(:state, @status_failed)
      |> Map.put(:step, "failed")
      |> Map.put(:message, "Project launch failed.")
      |> Map.put(:last_error, format_error(reason))

    {:noreply, %{state | action_ref: nil, status: status}}
  end

  def handle_info(_message, state), do: {:noreply, state}

  defp reply_with_start(_action, %{action_ref: _ref} = state) when not is_nil(state.action_ref) do
    {:reply, {:error, :busy}, state}
  end

  defp reply_with_start(action, state) do
    action_ref = make_ref()
    status = status_for_action(action)
    server = self()

    Task.start(fn ->
      result = run_action(server, action_ref, action)
      send(server, {:project_manager_complete, action_ref, result})
    end)

    {:reply, :ok, %{state | action_ref: action_ref, status: status}}
  end

  defp run_action(server, action_ref, {:open, project_root}) do
    with {:ok, normalized_root} <- validate_project_root(project_root),
         :ok <- install_dependencies(server, action_ref, normalized_root),
         :ok <- configure_runtime(normalized_root) do
      {:ok, normalized_root, recent_entry(normalized_root, nil)}
    end
  end

  defp run_action(server, action_ref, {:clone, repo_url, parent_dir}) do
    with {:ok, normalized_parent} <- validate_parent_dir(parent_dir),
         {:ok, target_root} <- clone_repository(server, action_ref, repo_url, normalized_parent),
         :ok <- install_dependencies(server, action_ref, target_root),
         :ok <- configure_runtime(target_root) do
      {:ok, target_root, recent_entry(target_root, repo_url)}
    end
  end

  defp run_action(server, action_ref, {:new_project, folder_path, project_name}) do
    normalized_folder = folder_path |> String.trim() |> Path.expand()
    project_root = Path.join(normalized_folder, project_name)

    with {:ok, _} <- validate_parent_dir(normalized_folder),
         :ok <- run_command(server, action_ref, "mix", ["phx.new", project_name, "--umbrella"], normalized_folder),
         :ok <- run_command(server, action_ref, "mix", ["foundry.init"], project_root),
         :ok <- install_dependencies(server, action_ref, project_root),
         :ok <- configure_runtime(project_root) do
      {:ok, project_root, recent_entry(project_root, nil)}
    end
  end

  defp validate_project_root(project_root) do
    normalized_root = project_root |> String.trim() |> Path.expand()

    cond do
      normalized_root == "" ->
        {:error, "Project path is required."}

      not File.dir?(normalized_root) ->
        {:error, "Project directory does not exist: #{normalized_root}"}

      not File.exists?(Path.join(normalized_root, "mix.exs")) ->
        {:error, "Expected a Mix project at #{normalized_root}"}

      true ->
        {:ok, normalized_root}
    end
  end

  defp validate_parent_dir(parent_dir) do
    normalized_parent = parent_dir |> String.trim() |> Path.expand()

    cond do
      normalized_parent == "" ->
        {:error, "Destination folder is required."}

      not File.dir?(normalized_parent) ->
        {:error, "Destination folder does not exist: #{normalized_parent}"}

      true ->
        {:ok, normalized_parent}
    end
  end

  defp clone_repository(server, action_ref, repo_url, parent_dir) do
    repo_name = derive_repo_name(repo_url)
    target_root = Path.join(parent_dir, repo_name)

    if File.exists?(target_root) do
      {:error, "Clone target already exists: #{target_root}"}
    else
      emit_log(server, action_ref, "$ git clone #{repo_url} #{target_root}\n")

      with :ok <- run_command(server, action_ref, "git", ["clone", repo_url, target_root], nil),
           {:ok, normalized_root} <- validate_project_root(target_root) do
        {:ok, normalized_root}
      end
    end
  end

  defp install_dependencies(server, action_ref, project_root) do
    emit_log(server, action_ref, "$ mix deps.get\n")
    run_command(server, action_ref, "mix", ["deps.get"], project_root)
  end

  defp configure_runtime(project_root) do
    Foundry.Studio.configure_runtime(project_root)
  end

  defp run_command(server, action_ref, executable, args, cd) do
    resolved = System.find_executable(executable)

    if is_nil(resolved) do
      {:error, "#{executable} is not installed or not on PATH."}
    else
      port =
        Port.open(
          {:spawn_executable, resolved},
          [
            :binary,
            :exit_status,
            :stderr_to_stdout,
            :use_stdio,
            :hide,
            args: args
          ] ++ port_cd_option(cd)
        )

      collect_command_output(server, action_ref, port, "")
    end
  end

  defp collect_command_output(server, action_ref, port, acc) do
    receive do
      {^port, {:data, chunk}} ->
        emit_log(server, action_ref, chunk)
        collect_command_output(server, action_ref, port, acc <> chunk)

      {^port, {:exit_status, 0}} ->
        :ok

      {^port, {:exit_status, status}} ->
        {:error, "Command failed with exit status #{status}.\n#{String.trim(acc)}"}
    after
      300_000 ->
        Port.close(port)
        {:error, "Command timed out."}
    end
  end

  defp emit_log(server, action_ref, chunk) do
    send(server, {:project_manager_log, action_ref, chunk})
  end

  defp port_cd_option(nil), do: []
  defp port_cd_option(cd), do: [cd: cd]

  defp derive_repo_name(repo_url) do
    repo_url
    |> String.trim()
    |> String.trim_trailing("/")
    |> Path.basename()
    |> String.replace_suffix(".git", "")
    |> case do
      "" -> "foundry-project"
      name -> name
    end
  end

  defp status_for_action({:open, project_root}) do
    %{
      state: @status_running,
      step: "installing_deps",
      message: "Installing project dependencies...",
      project_root: Path.expand(String.trim(project_root)),
      logs: "",
      last_error: nil
    }
  end

  defp status_for_action({:clone, repo_url, parent_dir}) do
    %{
      state: @status_running,
      step: "cloning",
      message: "Cloning repository...",
      project_root: Path.expand(Path.join(String.trim(parent_dir), derive_repo_name(repo_url))),
      logs: "",
      last_error: nil
    }
  end

  defp status_for_action({:new_project, folder_path, project_name}) do
    %{
      state: @status_running,
      step: "scaffolding",
      message: "Creating new project...",
      project_root: Path.join(Path.expand(String.trim(folder_path)), project_name),
      logs: "",
      last_error: nil
    }
  end

  defp default_status do
    %{
      state: @status_idle,
      step: nil,
      message: "Waiting for a project.",
      project_root: nil,
      logs: "",
      last_error: nil
    }
  end

  defp recent_entry(project_root, repo_url) do
    %{
      "root" => project_root,
      "label" => Path.basename(project_root),
      "repo_url" => repo_url
    }
  end

  defp prepend_recent(recent_projects, recent_entry) do
    [recent_entry | Enum.reject(recent_projects, &(&1["root"] == recent_entry["root"]))]
  end

  defp valid_recent_entry?(%{"root" => root}) when is_binary(root), do: File.dir?(root)
  defp valid_recent_entry?(_entry), do: false

  defp persisted_state_path do
    home_dir = System.get_env("FOUNDRY_HOME") || System.user_home!()
    Path.join([home_dir, ".foundry", "project_manager.json"])
  end

  defp load_persisted_state do
    path = persisted_state_path()

    with {:ok, body} <- File.read(path),
         {:ok, data} <- Jason.decode(body) do
      %{
        last_project_root: data["last_project_root"],
        recent_projects:
          (data["recent_projects"] || [])
          |> Enum.filter(&valid_recent_entry?/1)
          |> Enum.take(@recent_limit)
      }
    else
      _ -> %{last_project_root: nil, recent_projects: []}
    end
  end

  defp persist_state(last_project_root, recent_projects) do
    path = persisted_state_path()
    File.mkdir_p!(Path.dirname(path))

    payload = %{
      last_project_root: last_project_root,
      recent_projects: recent_projects
    }

    File.write!(path, Jason.encode!(payload))
  end

  defp append_log("", chunk), do: chunk
  defp append_log(existing, chunk), do: existing <> chunk

  defp format_error(reason) when is_binary(reason), do: reason
  defp format_error(reason), do: inspect(reason)
end