lib/graft/verify.ex

defmodule Graft.Verify do
  @moduledoc """
  Verify invariants against a workspace snapshot.

  Checks topology, ownership, drift, safety, and capability constraints
  before any materialization is attempted.

  Returns a list of violations, grouped by severity.

  ## Invariant Categories

  * **Topology** — dependency graph structural integrity
  * **Ownership** — filesystem permissions and isolation
  * **Drift** — divergence between expected and observed state
  * **Safety** — operational risk bounds
  * **Capability** — required resources and compatibility

  ## Usage

      snapshot = Workspace.snapshot(".")
      violations = Verify.check(snapshot)

      if Verify.pass?(violations) do
        # Safe to proceed with graft
      else
        Verify.report(violations)
      end
  """

  alias Graft.Workspace
  alias Graft.Workspace.Topology

  defmodule Violation do
    @moduledoc "A single invariant violation."

    @type severity :: :fatal | :error | :warning | :info

    @type t :: %__MODULE__{
            category: :topology | :ownership | :drift | :safety | :capability,
            severity: severity(),
            invariant: String.t(),
            target: String.t() | nil,
            message: String.t(),
            details: map()
          }

    defstruct [:category, :severity, :invariant, :target, :message, :details]
  end

  @doc """
  Check all invariants against a workspace snapshot.

  Returns a list of violations, empty if all invariants pass.
  """
  @spec check(Workspace.t()) :: [Violation.t()]
  def check(%Workspace{} = snapshot) do
    []
    |> check_topology(snapshot)
    |> check_ownership(snapshot)
    |> check_drift(snapshot)
    |> check_safety(snapshot)
    |> check_capabilities(snapshot)
    |> Enum.sort_by(&{severity_rank(&1.severity), &1.category, &1.invariant})
  end

  @doc """
  True if there are no fatal or error violations.

  Warnings may be present.
  """
  @spec pass?([Violation.t()]) :: boolean()
  def pass?(violations) do
    not Enum.any?(violations, &(&1.severity in [:fatal, :error]))
  end

  @doc """
  Format violations for human display.
  """
  @spec report([Violation.t()]) :: String.t()
  def report(violations) do
    violations
    |> Enum.group_by(& &1.severity)
    |> Enum.map(fn {severity, items} ->
      header = severity_header(severity)
      lines = Enum.map(items, &format_violation/1)
      [header, lines]
    end)
    |> IO.iodata_to_binary()
  end

  ## ─── Topology Invariants ──────────────────────────────────────────────

  defp check_topology(violations, %Workspace{topology: nil}) do
    [
      violation(
        :topology,
        :fatal,
        "topology_computed",
        "Topology not computed in snapshot. Run Workspace.snapshot/1 first."
      )
      | violations
    ]
  end

  defp check_topology(violations, %Workspace{topology: topology, repos: repos}) do
    violations
    |> check_no_cycles(topology)
    |> check_valid_topo_order(topology, repos)
    |> check_no_conflicting_app_names(topology, repos)
    |> check_external_apps_resolvable(topology, repos)
  end

  defp check_no_cycles(violations, %Topology{cyclic?: true}) do
    [
      violation(
        :topology,
        :fatal,
        "no_cycles",
        "Dependency graph contains cycles. Cyclic sibling dependencies cannot be grafted."
      )
      | violations
    ]
  end

  defp check_no_cycles(violations, %Topology{cyclic?: false}), do: violations

  defp check_valid_topo_order(violations, %Topology{topological_order: order}, repos) do
    repo_names = MapSet.new(repos, & &1.name)
    missing = MapSet.difference(repo_names, MapSet.new(order))

    if MapSet.size(missing) > 0 do
      [
        violation(
          :topology,
          :error,
          "valid_topo_order",
          "Topological order missing repos: #{inspect(MapSet.to_list(missing))}"
        )
        | violations
      ]
    else
      violations
    end
  end

  defp check_no_conflicting_app_names(violations, %Topology{providers: providers}, _repos) do
    # Multiple repos providing the same OTP app name
    conflicts =
      providers
      |> Enum.filter(fn {_repo, apps} -> length(apps) > 0 end)
      |> Enum.flat_map(fn {repo, apps} ->
        Enum.map(apps, fn app ->
          other_repos =
            providers
            |> Enum.filter(fn {r, a} -> r != repo and app in a end)
            |> Enum.map(&elem(&1, 0))

          if length(other_repos) > 0 do
            {repo, app, other_repos}
          else
            nil
          end
        end)
      end)
      |> Enum.reject(&is_nil/1)

    Enum.reduce(conflicts, violations, fn {repo, app, others}, acc ->
      [
        violation(
          :topology,
          :error,
          "no_conflicting_app_names",
          "Repo #{repo} provides app #{app}, but #{inspect(others)} also declare it as a dependency source"
        )
        | acc
      ]
    end)
  end

  defp check_external_apps_resolvable(violations, %Topology{external_apps: []}, _repos),
    do: violations

  defp check_external_apps_resolvable(violations, %Topology{external_apps: apps}, _repos) do
    # External apps are not errors — they just require hex resolution
    infos =
      Enum.map(apps, fn app ->
        violation(
          :topology,
          :info,
          "external_apps_resolvable",
          "App #{app} is not declared in workspace; will be resolved from Hex"
        )
      end)

    infos ++ violations
  end

  ## ─── Ownership Invariants ───────────────────────────────────────────

  defp check_ownership(violations, %Workspace{repos: repos, links: links}) do
    violations
    |> check_managed_repos_writable(repos, links)
    |> check_ephemeral_isolation(repos)
  end

  defp check_managed_repos_writable(violations, repos, links) do
    linked_repos = MapSet.new(links, & &1.repo)

    Enum.reduce(repos, violations, fn repo, acc ->
      if repo.exists? and MapSet.member?(linked_repos, repo.name) do
        mix_exs = Path.join(repo.absolute_path, "mix.exs")

        case File.stat(mix_exs) do
          {:ok, %{access: access}} when access in [:read_write, :write] ->
            acc

          {:ok, %{access: :read}} ->
            [
              violation(
                :ownership,
                :error,
                "managed_repos_writable",
                "Repo #{repo.name} mix.exs is read-only; graft cannot rewrite dependencies",
                %{path: mix_exs}
              )
              | acc
            ]

          {:error, reason} ->
            [
              violation(
                :ownership,
                :error,
                "managed_repos_writable",
                "Cannot stat #{repo.name} mix.exs: #{:file.format_error(reason)}",
                %{path: mix_exs, reason: reason}
              )
              | acc
            ]
        end
      else
        acc
      end
    end)
  end

  defp check_ephemeral_isolation(violations, repos) do
    # Check that ephemeral workspace paths (if any) are outside declared workspace root
    Enum.reduce(repos, violations, fn repo, acc ->
      if String.starts_with?(repo.absolute_path, System.tmp_dir!()) do
        [
          violation(
            :ownership,
            :warning,
            "ephemeral_isolation",
            "Repo #{repo.name} is in temp directory (#{repo.absolute_path}); ephemeral graft isolation may not be guaranteed"
          )
          | acc
        ]
      else
        acc
      end
    end)
  end

  ## ─── Drift Invariants ───────────────────────────────────────────────

  defp check_drift(violations, %Workspace{repos: repos, git: git_states, links: links}) do
    violations
    |> check_local_modifications(repos, git_states)
    |> check_detached_heads(repos, git_states)
    |> check_branch_divergence(repos, git_states)
    |> check_missing_mounts(links)
    |> check_orphaned_links(links, repos)
  end

  defp check_local_modifications(violations, _repos, git_states) do
    dirty_repos =
      git_states
      |> Enum.filter(& &1.dirty?)
      |> Enum.map(& &1.repo)

    Enum.reduce(dirty_repos, violations, fn repo_name, acc ->
      [
        violation(
          :drift,
          :warning,
          "local_modifications",
          "Repo #{repo_name} has uncommitted changes; graft may conflict"
        )
        | acc
      ]
    end)
  end

  defp check_detached_heads(violations, _repos, git_states) do
    detached =
      git_states
      |> Enum.filter(& &1.detached_head?)
      |> Enum.map(& &1.repo)

    Enum.reduce(detached, violations, fn repo_name, acc ->
      [
        violation(
          :drift,
          :warning,
          "detached_heads",
          "Repo #{repo_name} is on detached HEAD; graft operations may be ambiguous"
        )
        | acc
      ]
    end)
  end

  defp check_branch_divergence(violations, _repos, git_states) do
    diverged =
      git_states
      |> Enum.filter(fn gs -> gs.ahead > 0 and gs.behind > 0 end)

    Enum.reduce(diverged, violations, fn gs, acc ->
      [
        violation(
          :drift,
          :error,
          "branch_divergence",
          "Repo #{gs.repo} branch diverged: #{gs.ahead} ahead, #{gs.behind} behind #{gs.upstream}"
        )
        | acc
      ]
    end)
  end

  defp check_missing_mounts(violations, links) do
    missing =
      links
      |> Enum.filter(& &1.active)
      |> Enum.reject(fn link ->
        target = Path.join(link.target_path)
        File.dir?(target) or File.regular?(target)
      end)

    Enum.reduce(missing, violations, fn link, acc ->
      [
        violation(
          :drift,
          :error,
          "missing_mounts",
          "Link #{link.repo}#{link.target_path} target does not exist on disk"
        )
        | acc
      ]
    end)
  end

  defp check_orphaned_links(violations, links, repos) do
    repo_names = MapSet.new(repos, & &1.name)

    orphaned =
      links
      |> Enum.reject(fn link ->
        MapSet.member?(repo_names, link.repo)
      end)

    Enum.reduce(orphaned, violations, fn link, acc ->
      [
        violation(
          :drift,
          :warning,
          "orphaned_links",
          "Link references repo #{link.repo} which is not in current workspace snapshot"
        )
        | acc
      ]
    end)
  end

  ## ─── Safety Invariants ──────────────────────────────────────────────

  defp check_safety(violations, %Workspace{root: root, repos: repos}) do
    violations
    |> check_graft_root_confinement(root, repos)
    |> check_no_path_traversal(repos)
    |> check_rollback_feasibility(repos)
  end

  defp check_graft_root_confinement(violations, root, repos) do
    Enum.reduce(repos, violations, fn repo, acc ->
      if String.starts_with?(repo.absolute_path, root <> "/") or repo.absolute_path == root do
        acc
      else
        [
          violation(
            :safety,
            :fatal,
            "graft_root_confinement",
            "Repo #{repo.name} at #{repo.absolute_path} escapes workspace root #{root}"
          )
          | acc
        ]
      end
    end)
  end

  defp check_no_path_traversal(violations, repos) do
    Enum.reduce(repos, violations, fn repo, acc ->
      if String.contains?(repo.path, "..") or String.contains?(repo.absolute_path, "..") do
        [
          violation(
            :safety,
            :fatal,
            "no_path_traversal",
            "Repo #{repo.name} path contains '..' traversal: #{repo.path}"
          )
          | acc
        ]
      else
        acc
      end
    end)
  end

  defp check_rollback_feasibility(violations, repos) do
    # Check that every repo has mix.exs backup / can be restored
    Enum.reduce(repos, violations, fn repo, acc ->
      backup = Path.join([repo.absolute_path, ".graft", "backups"])

      if repo.exists? and not File.dir?(backup) do
        [
          violation(
            :safety,
            :warning,
            "rollback_feasibility",
            "Repo #{repo.name} has no .graft/backups directory; rollback may require manual git operations"
          )
          | acc
        ]
      else
        acc
      end
    end)
  end

  ## ─── Capability Invariants ──────────────────────────────────────────

  defp check_capabilities(violations, %Workspace{repos: repos, deps: deps}) do
    violations
    |> check_dependency_closure(repos, deps)
    |> check_compatible_versions(repos, deps)
  end

  defp check_dependency_closure(violations, repos, deps) do
    repo_names = MapSet.new(repos, & &1.name)
    dep_apps = MapSet.new(deps, & &1.app)

    # Every dep app must either be a declared repo or an external (hex/git) dep
    missing =
      dep_apps
      |> MapSet.difference(repo_names)
      |> MapSet.reject(fn _app ->
        # Is it a known external package? (would need hex metadata)
        # For now, assume external apps are okay (topology invariant already checked)
        true
      end)

    if MapSet.size(missing) == 0 do
      violations
    else
      [
        violation(
          :capability,
          :error,
          "dependency_closure",
          "Dependency closure incomplete: #{inspect(MapSet.to_list(missing))} not resolved"
        )
        | violations
      ]
    end
  end

  defp check_compatible_versions(violations, _repos, _deps) do
    # Version compatibility requires hex metadata; placeholder for now
    # Real implementation would check lockfile against declared ranges
    violations
  end

  ## ─── Helpers ─────────────────────────────────────────────────────────

  defp violation(category, severity, invariant, message, details \\ %{}) do
    %Violation{
      category: category,
      severity: severity,
      invariant: invariant,
      target: details[:target],
      message: message,
      details: details
    }
  end

  defp severity_rank(:fatal), do: 0
  defp severity_rank(:error), do: 1
  defp severity_rank(:warning), do: 2
  defp severity_rank(:info), do: 3

  defp severity_header(:fatal), do: "FATAL:\n"
  defp severity_header(:error), do: "ERROR:\n"
  defp severity_header(:warning), do: "WARNING:\n"
  defp severity_header(:info), do: "INFO:\n"

  defp format_violation(%Violation{invariant: inv, message: msg, target: nil}) do
    "  [#{inv}] #{msg}\n"
  end

  defp format_violation(%Violation{invariant: inv, message: msg, target: target}) do
    "  [#{inv}] #{msg} (target: #{target})\n"
  end
end