lib/graft/link/off/plan.ex

defmodule Graft.Link.Off.Plan do
  @moduledoc """
  Plans a `link.off` operation: restore mix.exs files rewritten by a
  prior `link.on` run from the literal preimages recorded in
  `.graft/state.json`.

  Pure: filesystem reads only (no writes), no git, no Mix shell-outs.
  Mirrors the architecture of `Graft.Link.Plan` — the planner is the
  semantic authority; the runner is purely mechanical.

  ## Inputs

    * `workspace` — `Graft.Workspace` snapshot.
    * `state`    — `Graft.State{}` loaded via `Graft.State.load/1`.
    * `target_apps` — non-empty list of target atoms to unlink.

  ## Validation

    * Every target must appear as `target_app` of at least one entry in
      `state.entries`. Unknown targets raise `:off_target_not_in_state`.
    * Each matched entry's `repo` must still be a declared sibling, and
      its `repo_path` must lie inside `workspace.root`. Fence violations
      raise `:off_workspace_violation`.

  ## Determinism

  Restorations are sorted by `(repo, target_app)`. `affected_repos`
  and `remaining_target_apps` are sorted, deduped atom lists. Only
  `:generated_at` differs across runs.
  """

  alias Graft.{Error, State, Workspace}
  alias Graft.Link.Off.Plan.Restoration

  @type t :: %__MODULE__{
          operation: :link_off,
          generated_at: DateTime.t(),
          workspace_root: Path.t(),
          target_apps: [atom()],
          affected_repos: [atom()],
          restorations: [Restoration.t()],
          remaining_entries: [State.Entry.t()],
          remaining_target_apps: [atom()],
          warnings: [String.t()]
        }

  defstruct operation: :link_off,
            generated_at: nil,
            workspace_root: nil,
            target_apps: [],
            affected_repos: [],
            restorations: [],
            remaining_entries: [],
            remaining_target_apps: [],
            warnings: []

  @spec build(Workspace.t(), State.t(), [atom()]) :: {:ok, t()} | {:error, Error.t()}
  def build(%Workspace{} = workspace, %State{} = state, target_apps)
      when is_list(target_apps) do
    targets = target_apps |> Enum.uniq() |> Enum.sort()

    with :ok <- validate_targets_nonempty(targets),
         :ok <- validate_targets_in_state(targets, state),
         {to_restore, to_keep} = split_entries(state.entries, targets),
         :ok <- validate_workspace_fence(to_restore, workspace) do
      restorations =
        to_restore
        |> Enum.sort_by(&{Atom.to_string(&1.repo), Atom.to_string(&1.target_app)})
        |> Enum.map(&entry_to_restoration/1)

      affected_repos =
        restorations
        |> Enum.map(& &1.repo)
        |> Enum.uniq()
        |> Enum.sort()

      remaining_target_apps =
        to_keep
        |> Enum.map(& &1.target_app)
        |> Enum.uniq()
        |> Enum.sort()

      {:ok,
       %__MODULE__{
         operation: :link_off,
         generated_at: DateTime.utc_now(),
         workspace_root: workspace.root,
         target_apps: targets,
         affected_repos: affected_repos,
         restorations: restorations,
         remaining_entries: to_keep,
         remaining_target_apps: remaining_target_apps,
         warnings: []
       }}
    end
  end

  ## ─── Validation ─────────────────────────────────────────────────────

  defp validate_targets_nonempty([]),
    do: {:error, Error.new(:plan_invalid_operation, "Target apps list must not be empty")}

  defp validate_targets_nonempty(_), do: :ok

  defp validate_targets_in_state(targets, %State{entries: entries}) do
    known = MapSet.new(entries, & &1.target_app)

    case Enum.reject(targets, &MapSet.member?(known, &1)) do
      [] ->
        :ok

      missing ->
        {:error,
         Error.new(
           :off_target_not_in_state,
           "Target app(s) have no recorded link in .graft/state.json: #{inspect(missing)}",
           %{targets: missing}
         )}
    end
  end

  defp validate_workspace_fence(entries, %Workspace{root: root, repos: repos}) do
    sibling_names = MapSet.new(repos, & &1.name)

    case Enum.find(entries, &fence_violation(&1, root, sibling_names)) do
      nil ->
        :ok

      bad ->
        reason =
          cond do
            not MapSet.member?(sibling_names, bad.repo) ->
              "repo #{inspect(bad.repo)} no longer declared in workspace"

            not inside_root?(bad.repo_path, root) ->
              "repo_path #{bad.repo_path} outside workspace root #{root}"

            not inside_root?(bad.mix_exs_path, root) ->
              "mix_exs_path #{bad.mix_exs_path} outside workspace root #{root}"
          end

        {:error,
         Error.new(
           :off_workspace_violation,
           "Refusing to restore #{inspect(bad.repo)}: #{reason}",
           %{
             repo: bad.repo,
             repo_path: bad.repo_path,
             mix_exs_path: bad.mix_exs_path,
             workspace_root: root
           }
         )}
    end
  end

  defp fence_violation(entry, root, sibling_names) do
    not MapSet.member?(sibling_names, entry.repo) or
      not inside_root?(entry.repo_path, root) or
      not inside_root?(entry.mix_exs_path, root)
  end

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

  ## ─── Splitting / projection ─────────────────────────────────────────

  defp split_entries(entries, targets) do
    target_set = MapSet.new(targets)

    Enum.reduce(entries, {[], []}, fn entry, {restore, keep} ->
      if MapSet.member?(target_set, entry.target_app) do
        {[entry | restore], keep}
      else
        {restore, [entry | keep]}
      end
    end)
    |> then(fn {r, k} -> {Enum.reverse(r), Enum.reverse(k)} end)
  end

  defp entry_to_restoration(entry) do
    %Restoration{
      repo: entry.repo,
      repo_path: entry.repo_path,
      target_app: entry.target_app,
      mix_exs_path: entry.mix_exs_path,
      mix_exs_before_hash: entry.mix_exs_before_hash,
      mix_exs_after_hash: entry.mix_exs_after_hash,
      preimage: entry.preimage,
      replacement: entry.replacement
    }
  end
end