lib/graft/link/runner.ex

defmodule Graft.Link.Runner do
  @moduledoc """
  Applies a `Graft.Link.Plan` transactionally.

  ## Atomicity contract

  Either every changed entry is applied **and** state.json is persisted,
  or every successfully-applied entry is rolled back to its byte-for-byte
  preimage. There is no partial-success end state.

  ## Algorithm

    1. Reject any change whose `repo_path` lies outside the plan's
       declared `workspace_root` (defence-in-depth — Plan already
       enforces this fence).
    2. Filter to effective changes (`changed?: true`); no-ops are
       skipped safely.
    3. For each effective change, in order:
       a. Read the consumer `mix.exs`.
       b. Verify its SHA-256 equals `change.mix_exs_before_hash`.
       c. Apply the literal `dependency_source_before → dependency_source_after`
          rewrite via `String.replace/3`.
       d. Verify the result's SHA-256 equals `change.proposed_mix_exs_after_hash`.
       e. Atomically write the new contents (write-tmp + rename).
       f. Push `(change, original_contents)` onto the rollback stack.
    4. Persist `Graft.State.from_plan(plan)`.
    5. On any failure in step 3 or 4, walk the rollback stack in LIFO
       order, restoring each consumer's `mix.exs` from its in-memory
       original.

  ## Why a literal find/replace and not a re-rewrite?

  The plan carries the post-rewrite hash. After applying the literal
  replacement we hash the result and verify equality with the planned
  hash. Any divergence (duplicate dep tuple appearing in a docstring,
  manual edit, etc.) shows up as a hash mismatch and aborts before
  writing. This keeps the runner free of Sourceror or path-resolution
  logic — those are planning-time concerns.

  ## Filesystem rules

    * Atomic writes: `File.write/2` to `<path>.graft.tmp` followed by
      `File.rename/2`. POSIX rename is atomic within a filesystem.
    * Temp files are removed if any preceding step fails.

  No git, no `mix deps.unlock`, no `mix deps.get`. Those integrate in
  later milestones.
  """

  alias Graft.{Error, Lock, State}
  alias Graft.Link.{Plan, Runner.Result}
  alias Graft.Link.Plan.Change

  @temp_suffix ".graft.tmp"

  @doc """
  Apply `plan` transactionally. Returns `{:ok, %Result{}}` on full
  success or `{:error, %Graft.Error{}}` on any failure (rollback is
  attempted automatically).
  """
  @spec run(Plan.t(), keyword()) :: {:ok, Result.t()} | {:error, Error.t()}
  def run(%Plan{} = plan, opts \\ []) when is_list(opts) do
    start_time = System.monotonic_time(:millisecond)

    Lock.with_lock(plan.workspace_root, fn ->
      with :ok <- check_fence(plan),
           :ok <- preflight_state(plan) do
        apply_and_save(plan, start_time)
      end
    end)
  end

  ## ─── Pre-flight: refuse mutation if existing state is corrupt ──────

  defp preflight_state(%Plan{changes: changes} = plan) do
    case Enum.any?(changes, & &1.changed?) do
      false ->
        :ok

      true ->
        case State.load_or_empty(plan.workspace_root) do
          {:ok, _state} ->
            :ok

          {:error, %Error{} = err} ->
            {:error,
             Error.new(
               :corrupt_state,
               "Refusing to mutate: existing state.json is unreadable: #{err.message}",
               %{cause: err.kind, message: err.message}
             )}
        end
    end
  end

  ## ─── Workspace fence (defence-in-depth) ─────────────────────────────

  defp check_fence(%Plan{workspace_root: root, changes: changes}) do
    case Enum.reject(changes, &inside_root?(&1.repo_path, root)) do
      [] ->
        :ok

      [bad | _] ->
        {:error,
         Error.new(
           :runner_fence_violation,
           "Refusing to mutate #{bad.repo_path}: outside workspace root #{root}",
           %{repo: bad.repo, repo_path: bad.repo_path, workspace_root: root}
         )}
    end
  end

  defp inside_root?(path, root) do
    path == root or String.starts_with?(path, root <> "/")
  end

  ## ─── Apply + persist orchestration ──────────────────────────────────

  defp apply_and_save(plan, start_time) do
    case apply_changes(plan) do
      {:ok, applied} ->
        case maybe_save_state(plan, applied) do
          :ok -> {:ok, build_result(applied, plan, start_time)}
          {:error, %Error{} = err} -> finalize_rollback(err, applied)
        end

      {:error, %Error{} = err, applied_so_far} ->
        finalize_rollback(err, applied_so_far)
    end
  end

  defp build_result(applied, plan, start_time) do
    duration = System.monotonic_time(:millisecond) - start_time

    %Result{
      applied_changes: Enum.map(applied, fn {c, _} -> c end),
      rolled_back_changes: [],
      state_path: State.state_path(plan.workspace_root),
      duration_ms: duration
    }
  end

  ## ─── Apply phase ────────────────────────────────────────────────────

  # Returns `{:ok, applied}` where `applied` is `[{change, original}]`
  # in application order. On failure returns `{:error, err, applied}`
  # so the caller can roll back `applied` LIFO.
  defp apply_changes(%Plan{changes: changes}) do
    effective = Enum.filter(changes, & &1.changed?)

    Enum.reduce_while(effective, {:ok, []}, fn change, {:ok, applied_rev} ->
      case apply_one(change) do
        {:ok, original} ->
          {:cont, {:ok, [{change, original} | applied_rev]}}

        {:error, %Error{} = err} ->
          {:halt, {:error, err, Enum.reverse(applied_rev)}}
      end
    end)
    |> case do
      {:ok, rev} -> {:ok, Enum.reverse(rev)}
      err -> err
    end
  end

  defp apply_one(%Change{} = change) do
    mix_exs_path = Path.join(change.repo_path, "mix.exs")

    with {:ok, original} <- read_file(mix_exs_path, change),
         :ok <- verify_before_hash(original, change),
         {:ok, rewritten} <- compute_rewrite(original, change),
         :ok <- verify_after_hash(rewritten, change),
         :ok <- write_atomic(mix_exs_path, rewritten) do
      {:ok, original}
    end
  end

  defp read_file(path, change) do
    case File.read(path) do
      {:ok, contents} ->
        {:ok, contents}

      {:error, reason} ->
        {:error,
         Error.new(
           :runner_write_failed,
           "Could not read #{path}: #{:file.format_error(reason)}",
           %{repo: change.repo, path: path, reason: reason, phase: :read}
         )}
    end
  end

  defp verify_before_hash(contents, %Change{} = change) do
    actual = State.hash_contents(contents)

    if actual == change.mix_exs_before_hash do
      :ok
    else
      {:error,
       Error.new(
         :runner_hash_mismatch,
         "Pre-rewrite hash mismatch for #{change.repo} (#{Path.join(change.repo_path, "mix.exs")}): file has changed since planning",
         %{
           repo: change.repo,
           expected: change.mix_exs_before_hash,
           actual: actual,
           phase: :before
         }
       )}
    end
  end

  defp compute_rewrite(original, %Change{} = change) do
    rewritten =
      String.replace(
        original,
        change.dependency_source_before,
        change.dependency_source_after
      )

    {:ok, rewritten}
  end

  defp verify_after_hash(rewritten, %Change{} = change) do
    actual = State.hash_contents(rewritten)

    if actual == change.proposed_mix_exs_after_hash do
      :ok
    else
      {:error,
       Error.new(
         :runner_hash_mismatch,
         "Post-rewrite hash mismatch for #{change.repo}: literal substitution diverged from the plan",
         %{
           repo: change.repo,
           expected: change.proposed_mix_exs_after_hash,
           actual: actual,
           phase: :after
         }
       )}
    end
  end

  ## ─── Atomic write ───────────────────────────────────────────────────

  defp write_atomic(path, contents) do
    tmp = path <> @temp_suffix

    with :ok <- write_temp(tmp, contents),
         :ok <- rename(tmp, path) do
      :ok
    else
      {:error, %Error{}} = err ->
        _ = File.rm(tmp)
        err
    end
  end

  defp write_temp(tmp, contents) do
    case File.write(tmp, contents) do
      :ok ->
        :ok

      {:error, reason} ->
        {:error,
         Error.new(
           :runner_write_failed,
           "Failed to write #{tmp}: #{:file.format_error(reason)}",
           %{path: tmp, reason: reason, phase: :write_temp}
         )}
    end
  end

  defp rename(tmp, path) do
    case File.rename(tmp, path) do
      :ok ->
        :ok

      {:error, reason} ->
        {:error,
         Error.new(
           :runner_write_failed,
           "Failed to rename #{tmp}#{path}: #{:file.format_error(reason)}",
           %{from: tmp, to: path, reason: reason, phase: :rename}
         )}
    end
  end

  ## ─── State save (only after every write succeeds) ───────────────────

  defp maybe_save_state(_plan, []), do: :ok

  defp maybe_save_state(plan, _applied) do
    with {:ok, existing} <- load_existing(plan),
         merged = State.merge_with_plan(existing, plan),
         :ok <- save_merged(plan, merged) do
      :ok
    end
  end

  defp load_existing(plan) do
    case State.load_or_empty(plan.workspace_root) do
      {:ok, state} ->
        {:ok, state}

      {:error, %Error{} = err} ->
        {:error,
         Error.new(
           :runner_state_persist_failed,
           "Refusing to overwrite unreadable state: #{err.message}",
           %{cause: err.kind, message: err.message}
         )}
    end
  end

  defp save_merged(plan, merged) do
    case State.save(plan.workspace_root, merged) do
      :ok ->
        :ok

      {:error, %Error{} = err} ->
        {:error,
         Error.new(
           :runner_state_persist_failed,
           "State persist failed: #{err.message}",
           %{cause: err.kind, message: err.message}
         )}
    end
  end

  ## ─── Rollback ───────────────────────────────────────────────────────

  defp finalize_rollback(original_err, applied) do
    case rollback(applied) do
      :ok ->
        {:error, original_err}

      {:rollback_failures, failures} ->
        {:error,
         Error.new(
           :runner_rollback_failed,
           "Rollback failed for #{length(failures)} repo(s) after error: #{original_err.message}",
           %{
             original_error_kind: original_err.kind,
             original_error_message: original_err.message,
             rollback_failures:
               Enum.map(failures, fn {change, e} ->
                 %{repo: change.repo, message: e.message}
               end)
           }
         )}
    end
  end

  # Walk the rollback stack LIFO, restoring each consumer's mix.exs to
  # its recorded preimage. Failures collected and reported.
  defp rollback(applied) do
    failures =
      applied
      |> Enum.reverse()
      |> Enum.reduce([], fn {change, original}, acc ->
        mix_exs_path = Path.join(change.repo_path, "mix.exs")

        case write_atomic(mix_exs_path, original) do
          :ok -> acc
          {:error, %Error{} = e} -> [{change, e} | acc]
        end
      end)

    case failures do
      [] -> :ok
      _ -> {:rollback_failures, failures}
    end
  end
end