lib/graft/link/off/runner.ex

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

  ## Algorithm

    1. For each restoration, in plan order:
       a. Read consumer mix.exs.
       b. Verify SHA-256 equals `mix_exs_after_hash` (the post-link
          hash recorded by `link.on`). Tampering or stale state aborts
          with `:off_hash_mismatch`.
       c. Replace `replacement` literally with `preimage`.
       d. Verify the result's SHA-256 equals `mix_exs_before_hash`
          (sanity — the original pre-link hash). Mismatch is also
          `:off_hash_mismatch`.
       e. Atomically write (write-tmp + rename).
       f. Push `(restoration, original_contents)` onto rollback stack.
    2. Save the pruned state (`plan.remaining_entries`) — or delete the
       state file when the list is empty. State update is itself
       transactional: failure rolls back every restored mix.exs.
    3. On any restore failure, walk the rollback stack LIFO and restore
       in-memory originals.

  Restoration is always *literal* — Off never reasons about AST. The
  only authority over what gets restored is the recorded preimage.
  """

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

  @temp_suffix ".graft.tmp"

  @spec run(Plan.t()) :: {:ok, Result.t()} | {:error, Error.t()}
  def run(%Plan{} = plan) do
    Lock.with_lock(plan.workspace_root, fn -> do_run(plan) end)
  end

  defp do_run(%Plan{} = plan) do
    start_time = System.monotonic_time(:millisecond)

    case restore_all(plan.restorations) do
      {:ok, applied} ->
        case persist_state(plan) do
          {:ok, deleted?} ->
            {:ok, build_result(applied, plan, deleted?, start_time)}

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

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

  ## ─── Restore phase ──────────────────────────────────────────────────

  defp restore_all(restorations) do
    Enum.reduce_while(restorations, {:ok, []}, fn r, {:ok, applied_rev} ->
      case restore_one(r) do
        {:ok, original} ->
          {:cont, {:ok, [{r, 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 restore_one(%Restoration{} = r) do
    with {:ok, current} <- read_file(r.mix_exs_path, r),
         :ok <- verify_after_hash(current, r),
         {:ok, restored} <- compute_restore(current, r),
         :ok <- verify_before_hash(restored, r),
         :ok <- write_atomic(r.mix_exs_path, restored) do
      {:ok, current}
    end
  end

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

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

  defp verify_after_hash(contents, %Restoration{} = r) do
    actual = State.hash_contents(contents)

    if actual == r.mix_exs_after_hash do
      :ok
    else
      {:error,
       Error.new(
         :off_hash_mismatch,
         "Pre-restore hash mismatch for #{r.repo} (#{r.mix_exs_path}): file has changed since link.on",
         %{
           repo: r.repo,
           expected: r.mix_exs_after_hash,
           actual: actual,
           phase: :before_restore
         }
       )}
    end
  end

  defp compute_restore(current, %Restoration{} = r) do
    {:ok, String.replace(current, r.replacement, r.preimage)}
  end

  defp verify_before_hash(restored, %Restoration{} = r) do
    actual = State.hash_contents(restored)

    if actual == r.mix_exs_before_hash do
      :ok
    else
      {:error,
       Error.new(
         :off_hash_mismatch,
         "Post-restore hash mismatch for #{r.repo}: literal restoration diverged from recorded preimage",
         %{
           repo: r.repo,
           expected: r.mix_exs_before_hash,
           actual: actual,
           phase: :after_restore
         }
       )}
    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(
           :off_restore_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(
           :off_restore_failed,
           "Failed to rename #{tmp}#{path}: #{:file.format_error(reason)}",
           %{from: tmp, to: path, reason: reason, phase: :rename}
         )}
    end
  end

  ## ─── State persistence (after every restore succeeds) ───────────────

  # Returns `{:ok, deleted?}` or `{:error, Error}`.
  defp persist_state(%Plan{remaining_entries: []} = plan) do
    path = State.state_path(plan.workspace_root)

    case File.exists?(path) do
      false ->
        {:ok, false}

      true ->
        case File.rm(path) do
          :ok ->
            {:ok, true}

          {:error, reason} ->
            {:error,
             Error.new(
               :off_state_update_failed,
               "Failed to delete #{path}: #{:file.format_error(reason)}",
               %{path: path, reason: reason}
             )}
        end
    end
  end

  defp persist_state(%Plan{} = plan) do
    new_state = %State{
      version: State.schema_version(),
      workspace_root: plan.workspace_root,
      generated_at: DateTime.to_iso8601(DateTime.utc_now()),
      entries: plan.remaining_entries
    }

    case State.save(plan.workspace_root, new_state) do
      :ok ->
        {:ok, false}

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

  ## ─── Result + rollback ──────────────────────────────────────────────

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

    %Result{
      restored: Enum.map(applied, fn {r, _} -> r end),
      rolled_back: [],
      remaining_entries: length(plan.remaining_entries),
      remaining_target_apps: plan.remaining_target_apps,
      state_path: State.state_path(plan.workspace_root),
      state_deleted?: deleted?,
      duration_ms: duration
    }
  end

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

      {:rollback_failures, failures} ->
        {:error,
         Error.new(
           :off_restore_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 {r, e} ->
                 %{repo: r.repo, message: e.message}
               end)
           }
         )}
    end
  end

  defp rollback(applied) do
    failures =
      applied
      |> Enum.reverse()
      |> Enum.reduce([], fn {r, original}, acc ->
        case write_atomic(r.mix_exs_path, original) do
          :ok -> acc
          {:error, %Error{} = e} -> [{r, e} | acc]
        end
      end)

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