lib/foundry/preview_server.ex

defmodule Foundry.PreviewServer.OutputCollector do
  @moduledoc false

  defstruct [:owner, :runner_pid]
end

defimpl Collectable, for: Foundry.PreviewServer.OutputCollector do
  def into(%Foundry.PreviewServer.OutputCollector{} = collector) do
    collector_fun = fn
      collector, {:cont, data} ->
        send(
          collector.owner,
          {:preview_output, collector.runner_pid, IO.iodata_to_binary(data)}
        )

        collector

      collector, :done ->
        collector

      _collector, :halt ->
        :ok
    end

    {collector, collector_fun}
  end
end

defmodule Foundry.PreviewServer do
  @moduledoc """
  Manages a dev server subprocess for live preview of reference projects.

  Spawns and monitors a server process (e.g., `mix phx.server`) from a manifest configuration.
  Provides start/stop events accessible via Foundry Studio UI.
  """

  use GenServer

  require Logger

  @default_preview_port 4001
  @startup_check_delay_ms 250
  @startup_timeout_ms 10_000
  @delay_to_sigkill_ms 1_000

  @state_idle :idle
  @state_starting :starting
  @state_running :running
  @state_stopping :stopping
  @state_failed :failed

  def start_link(config) do
    GenServer.start_link(__MODULE__, config, name: __MODULE__)
  end

  def get_status do
    GenServer.call(__MODULE__, :status, 5000)
  catch
    :exit, _ -> {:error, :not_started}
  end

  def start_preview(project_root) do
    GenServer.cast(__MODULE__, {:start, project_root})
  end

  def stop_preview do
    GenServer.cast(__MODULE__, :stop)
  end

  def preview_base_url(project_root) do
    case load_manifest_config(project_root) do
      {:ok, config} ->
        port = config[:port] || @default_preview_port
        "http://localhost:#{port}"

      {:error, _reason} ->
        "http://localhost:#{@default_preview_port}"
    end
  end

  @impl true
  def init(_config) do
    {:ok,
     %{
       state: @state_idle,
       project_root: nil,
       command: nil,
       env: [],
       port_num: nil,
       os_pid: nil,
       startup_started_at: nil,
       last_activity_at: nil,
       output: "",
       output_buffer: "",
       last_error: nil,
       runner_pid: nil,
       runner_ref: nil,
       pending_terminal_state: nil
     }}
  end

  @impl true
  def handle_call(:status, _from, state) do
    status = %{
      state: state.state,
      port: state.port_num,
      project_root: state.project_root,
      url: if(state.port_num, do: "http://localhost:#{state.port_num}", else: nil),
      os_pid: state.os_pid,
      command: state.command,
      startup_started_at: state.startup_started_at,
      last_activity_at: state.last_activity_at,
      output: state.output,
      last_error: state.last_error
    }

    {:reply, {:ok, status}, state}
  end

  @impl true
  def handle_cast({:start, _project_root}, %{state: state} = current_state)
      when state in [@state_starting, @state_running, @state_stopping] do
    {:noreply, current_state}
  end

  def handle_cast({:start, project_root}, state) do
    case load_manifest_config(project_root) do
      {:ok, config} ->
        with {:ok, command_spec} <- normalize_command(config[:command] || "mix phx.server") do
          new_state = %{
            state
            | state: @state_starting,
              project_root: project_root,
              command: command_spec.command,
              env: config[:env] || [],
              port_num: config[:port] || @default_preview_port,
              os_pid: nil,
              startup_started_at: now_ms(),
              last_activity_at: now_ms(),
              output: "",
              output_buffer: "",
              last_error: nil,
              pending_terminal_state: nil
          }

          {:noreply, start_server_process(new_state, command_spec)}
        else
          {:error, reason} ->
            Logger.error("Failed to normalize preview command: #{reason}")
            {:noreply, %{state | state: @state_failed, last_error: reason}}
        end

      {:error, reason} ->
        Logger.error("Failed to load manifest config: #{reason}")
        {:noreply, %{state | state: @state_failed, last_error: reason}}
    end
  end

  @impl true
  def handle_cast(:stop, %{state: @state_idle, runner_pid: nil} = state) do
    {:noreply, state}
  end

  def handle_cast(:stop, %{state: @state_failed, runner_pid: nil} = state) do
    {:noreply, reset_failed_state(state)}
  end

  def handle_cast(:stop, state) do
    stop_runner(state)

    {:noreply,
     %{
       state
       | state: @state_stopping,
         pending_terminal_state: @state_idle
     }}
  end

  @impl true
  def terminate(_reason, state) do
    stop_runner(state)
    :ok
  end

  @impl true
  def handle_info(:check_started, %{state: @state_starting} = state) do
    cond do
      preview_reachable?(state.port_num) ->
        {:noreply, %{state | state: @state_running, last_error: nil}}

      startup_timed_out?(state) ->
        stop_runner(state)

        {:noreply,
         %{
           state
           | state: @state_failed,
             startup_started_at: nil,
             last_activity_at: nil,
             last_error: startup_timeout_error(state),
             pending_terminal_state: @state_failed
         }}

      runner_alive?(state.runner_pid) ->
        Process.send_after(self(), :check_started, @startup_check_delay_ms)
        {:noreply, state}

      true ->
        {:noreply, state}
    end
  end

  def handle_info({:preview_output, runner_pid, data}, %{runner_pid: runner_pid} = state) do
    next_state = append_output(state, data)
    log_preview_output(data)

    case build_lock_error(data) do
      nil ->
        {:noreply, next_state}

      error ->
        stop_runner(next_state)

        {:noreply,
         %{
           next_state
           | state: @state_failed,
             startup_started_at: nil,
             last_activity_at: nil,
             last_error: error,
             pending_terminal_state: @state_failed
         }}
    end
  end

  def handle_info({:preview_output, _runner_pid, _data}, state) do
    {:noreply, state}
  end

  def handle_info({:preview_exit, runner_pid, exit_result}, %{runner_pid: runner_pid} = state) do
    Logger.info("Preview server exited with result: #{inspect(exit_result)}")
    {:noreply, finalize_runner_exit(state, exit_result)}
  end

  def handle_info({:preview_exit, _runner_pid, _exit_result}, state) do
    {:noreply, state}
  end

  def handle_info(
        {:DOWN, ref, :process, runner_pid, reason},
        %{runner_ref: ref, runner_pid: runner_pid} = state
      ) do
    next_state =
      if state.runner_pid == nil do
        state
      else
        finalize_runner_exit(state, down_reason_to_exit_result(reason))
      end

    {:noreply, next_state}
  end

  @impl true
  def handle_info(msg, state) do
    Logger.debug("PreviewServer ignoring message: #{inspect(msg)}")
    {:noreply, state}
  end

  defp load_manifest_config(project_root) do
    manifest_path = Path.join(project_root, "manifest.exs")

    case File.read(manifest_path) do
      {:ok, content} ->
        try do
          config = Code.eval_string(content, []) |> elem(0)
          preview_server_config = Keyword.get(config, :preview_server, [])
          {:ok, preview_server_config}
        rescue
          e ->
            {:error, "Failed to parse manifest: #{inspect(e)}"}
        end

      {:error, reason} ->
        {:error, "Failed to read manifest: #{reason}"}
    end
  end

  defp start_server_process(%{state: @state_starting} = state, command_spec) do
    case start_runner(self(), state, command_spec) do
      {:ok, runner_pid} ->
        runner_ref = Process.monitor(runner_pid)
        Process.send_after(self(), :check_started, @startup_check_delay_ms)

        Logger.info(
          "Preview server started on port #{state.port_num} with command #{command_spec.command} in #{state.project_root}"
        )

        %{state | runner_pid: runner_pid, runner_ref: runner_ref}

      {:error, reason} ->
        Logger.error("Failed to start preview server: #{reason}")

        %{
          state
          | state: @state_failed,
            startup_started_at: nil,
            last_activity_at: nil,
            last_error: reason
        }
    end
  end

  defp start_runner(owner, state, command_spec) do
    Task.start(fn ->
      run_preview_command(owner, state, command_spec)
    end)
  end

  defp run_preview_command(owner, state, command_spec) do
    collector = %Foundry.PreviewServer.OutputCollector{owner: owner, runner_pid: self()}

    opts = [
      cd: state.project_root,
      env: normalized_env(state),
      stderr_to_stdout: true,
      delay_to_sigkill: @delay_to_sigkill_ms,
      into: collector
    ]

    exit_result =
      try do
        case MuonTrap.cmd(command_spec.executable, command_spec.args, opts) do
          {_collector, status} -> normalize_exit_result(status)
          other -> normalize_exit_result(other)
        end
      rescue
        error -> {:error, Exception.message(error)}
      catch
        kind, reason -> {:error, Exception.format(kind, reason, __STACKTRACE__)}
      end

    send(owner, {:preview_exit, self(), exit_result})
  end

  defp normalized_env(state) do
    state.env
    |> Kernel.++([
      {"MIX_ENV", "dev"},
      {"PORT", Integer.to_string(state.port_num)}
    ])
    |> Enum.map(&normalize_env_entry/1)
  end

  defp normalize_command(command) when is_binary(command) do
    case OptionParser.split(command) do
      [] ->
        {:error, "Preview command is empty."}

      [executable | args] ->
        {:ok, %{command: command, executable: executable, args: args}}
    end
  end

  defp normalize_command(command) when is_list(command) do
    command
    |> Enum.map(&normalize_command_part/1)
    |> case do
      [] ->
        {:error, "Preview command is empty."}

      [executable | args] ->
        {:ok,
         %{
           command: Enum.join(Enum.map([executable | args], &inspect/1), " "),
           executable: executable,
           args: args
         }}
    end
  rescue
    _ ->
      {:error, "Preview command list must contain only string-like arguments."}
  end

  defp normalize_command(_command) do
    {:error, "Preview command must be a string or a list of arguments."}
  end

  defp normalize_command_part(part) when is_binary(part), do: part
  defp normalize_command_part(part) when is_atom(part), do: Atom.to_string(part)
  defp normalize_command_part(part) when is_list(part), do: List.to_string(part)
  defp normalize_command_part(part), do: to_string(part)

  defp finalize_runner_exit(state, exit_result) do
    {next_state, last_error} =
      cond do
        state.pending_terminal_state == @state_idle ->
          {@state_idle, nil}

        state.pending_terminal_state == @state_failed ->
          {@state_failed, state.last_error}

        state.state == @state_starting and exit_result == 0 ->
          {@state_failed, "Preview process exited before opening the HTTP port."}

        exit_result == 0 ->
          {@state_idle, state.last_error}

        is_integer(exit_result) ->
          {@state_failed, "Preview server exited with status #{exit_result}"}

        match?({:error, _}, exit_result) ->
          {:error, reason} = exit_result
          {@state_failed, reason}

        true ->
          {@state_failed, "Preview server exited unexpectedly"}
      end

    clear_runner_state(state, next_state, last_error)
  end

  defp clear_runner_state(state, next_state, last_error) do
    %{
      state
      | state: next_state,
        runner_pid: nil,
        runner_ref: nil,
        os_pid: nil,
        startup_started_at: nil,
        last_activity_at: nil,
        last_error: last_error,
        pending_terminal_state: nil
    }
  end

  defp reset_failed_state(state) do
    %{
      state
      | state: @state_idle,
        output: "",
        output_buffer: "",
        last_error: nil,
        startup_started_at: nil,
        last_activity_at: nil,
        pending_terminal_state: nil
    }
  end

  defp normalize_exit_result(status) when is_integer(status), do: status
  defp normalize_exit_result({:error, _reason} = error), do: error
  defp normalize_exit_result(:timeout), do: {:error, "Preview command timed out."}

  defp normalize_exit_result(other),
    do: {:error, "Preview command exited unexpectedly: #{inspect(other)}"}

  defp down_reason_to_exit_result(:normal), do: 0
  defp down_reason_to_exit_result(:shutdown), do: 0
  defp down_reason_to_exit_result({:shutdown, _}), do: 0
  defp down_reason_to_exit_result({:error, _reason} = error), do: error

  defp down_reason_to_exit_result(reason),
    do: {:error, "Preview command crashed: #{inspect(reason)}"}

  defp preview_reachable?(nil), do: false

  defp preview_reachable?(port_num) do
    case :gen_tcp.connect({127, 0, 0, 1}, port_num, [:binary, active: false], 100) do
      {:ok, socket} ->
        :gen_tcp.close(socket)
        true

      _ ->
        false
    end
  end

  defp startup_timed_out?(%{startup_started_at: nil}), do: false

  defp startup_timed_out?(state) do
    now_ms() - (state.last_activity_at || state.startup_started_at) >= @startup_timeout_ms
  end

  defp runner_alive?(nil), do: false
  defp runner_alive?(pid), do: Process.alive?(pid)

  defp stop_runner(%{runner_pid: nil}), do: :ok

  defp stop_runner(%{runner_pid: runner_pid}) do
    if Process.alive?(runner_pid) do
      Process.exit(runner_pid, :shutdown)
    end

    :ok
  end

  defp append_output(state, data) do
    normalized = String.replace(data, "\r\n", "\n")
    combined = state.output_buffer <> normalized
    raw_output = state.output <> normalized

    {complete_lines, next_buffer} =
      if String.ends_with?(combined, "\n") do
        {String.split(combined, "\n", trim: true), ""}
      else
        case String.split(combined, "\n") do
          [] -> {[], ""}
          parts -> {Enum.drop(parts, -1), List.last(parts)}
        end
      end

    last_error =
      if state.state == @state_starting and
           Enum.any?(complete_lines, &String.contains?(String.downcase(&1), "error")) do
        List.last(complete_lines)
      else
        state.last_error
      end

    %{
      state
      | output: raw_output,
        output_buffer: next_buffer,
        last_activity_at: now_ms(),
        last_error: last_error
    }
  end

  defp log_preview_output(data) when is_binary(data) do
    data
    |> String.split("\n", trim: true)
    |> Enum.each(fn line ->
      cond do
        String.downcase(line) =~ ~r/error|exception|failed/ ->
          Logger.error("[IGAMING] #{line}")

        String.downcase(line) =~ ~r/warn/ ->
          Logger.warning("[IGAMING] #{line}")

        true ->
          Logger.debug("[IGAMING] #{line}")
      end
    end)
  end

  defp build_lock_error(data) do
    if String.contains?(data, "Waiting for lock on the build directory") do
      case Regex.run(~r/held by process\s+(\d+)/, data) do
        [_, pid] ->
          "Preview build is blocked by another Mix process holding the build lock (PID #{pid})."

        _ ->
          "Preview build is blocked by another Mix process holding the build lock."
      end
    else
      nil
    end
  end

  defp startup_timeout_error(state) do
    base =
      "Preview HTTP port #{state.port_num} did not open within #{@startup_timeout_ms}ms after the last process output."

    cond do
      is_binary(state.last_error) and state.last_error != "" ->
        "#{base} Last detected error output: #{state.last_error}"

      last_output_line = last_output_line(state.output) ->
        "#{base} No explicit error was emitted. Last log line: #{last_output_line}"

      true ->
        "#{base} No process output was captured."
    end
  end

  defp last_output_line(output) when is_binary(output) do
    output
    |> String.split("\n", trim: true)
    |> List.last()
  end

  defp last_output_line(_output), do: nil

  defp now_ms, do: System.monotonic_time(:millisecond)

  defp normalize_env_entry({key, value}) do
    {to_env_string(key), to_env_string(value)}
  end

  defp to_env_string(value) when is_binary(value), do: value
  defp to_env_string(value) when is_atom(value), do: Atom.to_string(value)
  defp to_env_string(value) when is_list(value), do: List.to_string(value)
  defp to_env_string(value), do: to_string(value)
end