lib/graft/link/plan/render.ex

defmodule Graft.Link.Plan.Render do
  @moduledoc """
  Pure rendering of `Graft.Link.Plan` values for CLI consumption.

  Two formats:

    * `:text` — human-readable summary + per-change blocks.
    * `:json` — structured map serialized via Jason; deterministic
      ordering for agent/jq consumption.

  Performs no IO and does not rescan or mutate the plan.
  """

  alias Graft.CLI.Errors
  alias Graft.Link.Plan
  alias Graft.Link.Plan.Change
  alias Graft.Link.Runner.Result

  @type format :: :text | :json

  @spec render(Plan.t(), format()) :: String.t()
  def render(plan, format \\ :text)
  def render(%Plan{} = plan, :text), do: render_text(plan)
  def render(%Plan{} = plan, :json), do: render_json(plan)

  @spec render_applied(Plan.t(), Result.t(), format()) :: String.t()
  def render_applied(plan, result, format \\ :text)

  def render_applied(%Plan{} = plan, %Result{} = result, :text),
    do: render_applied_text(plan, result)

  def render_applied(%Plan{} = plan, %Result{} = result, :json),
    do: render_applied_json(plan, result)

  ## ─── Text ───────────────────────────────────────────────────────────

  defp render_text(%Plan{} = plan) do
    header = [
      "Graft link.#{operation_label(plan.operation)} (dry-run)",
      "Workspace: #{plan.workspace_root}",
      "Targets: #{Enum.map_join(plan.target_apps, ", ", &Atom.to_string/1)}",
      "Affected repos: #{length(plan.affected_repos)}",
      "Changes: #{length(plan.changes)}",
      ""
    ]

    body =
      case plan.changes do
        [] -> ["(no changes required)"]
        changes -> Enum.flat_map(changes, &change_block/1)
      end

    warnings =
      case plan.warnings do
        [] -> []
        ws -> ["", "Warnings:" | Enum.map(ws, &"  - #{&1}")]
      end

    (header ++ body ++ warnings)
    |> Enum.intersperse("\n")
    |> IO.iodata_to_binary()
  end

  defp change_block(%Change{} = c) do
    [
      "#{c.repo}#{c.target_app}",
      "  before: #{c.dependency_source_before}",
      "  after:  #{c.dependency_source_after}",
      "  changed: #{if c.changed?, do: "yes", else: "no"}",
      ""
    ]
  end

  defp operation_label(:link_on), do: "on"
  defp operation_label(:link_off), do: "off"

  ## ─── JSON ───────────────────────────────────────────────────────────

  defp render_json(%Plan{} = plan) do
    %{
      operation: to_string(plan.operation),
      dry_run: true,
      workspace_root: plan.workspace_root,
      generated_at: DateTime.to_iso8601(plan.generated_at),
      target_apps: Enum.map(plan.target_apps, &Atom.to_string/1),
      affected_repos: Enum.map(plan.affected_repos, &Atom.to_string/1),
      changes: Enum.map(plan.changes, &change_json/1),
      warnings: plan.warnings
    }
    |> Errors.jsonable()
    |> Jason.encode!()
  end

  ## ─── Applied (text) ─────────────────────────────────────────────────

  defp render_applied_text(%Plan{} = plan, %Result{} = result) do
    header = [
      "Graft link.#{operation_label(plan.operation)} (applied)",
      "Workspace: #{plan.workspace_root}",
      "Targets: #{Enum.map_join(plan.target_apps, ", ", &Atom.to_string/1)}",
      "Affected repos: #{length(plan.affected_repos)}",
      "Applied changes: #{length(result.applied_changes)}",
      "State: #{result.state_path}",
      "Duration: #{result.duration_ms}ms",
      ""
    ]

    body =
      case result.applied_changes do
        [] -> ["(no changes applied)"]
        changes -> Enum.map(changes, fn c -> "  #{c.repo}#{c.target_app}" end)
      end

    (header ++ body)
    |> Enum.intersperse("\n")
    |> IO.iodata_to_binary()
  end

  ## ─── Applied (JSON) ─────────────────────────────────────────────────

  defp render_applied_json(%Plan{} = plan, %Result{} = result) do
    %{
      operation: to_string(plan.operation),
      dry_run: false,
      workspace_root: plan.workspace_root,
      generated_at: DateTime.to_iso8601(plan.generated_at),
      target_apps: Enum.map(plan.target_apps, &Atom.to_string/1),
      affected_repos: Enum.map(plan.affected_repos, &Atom.to_string/1),
      applied_changes: Enum.map(result.applied_changes, &applied_change_json/1),
      state_path: result.state_path,
      duration_ms: result.duration_ms,
      warnings: plan.warnings
    }
    |> Errors.jsonable()
    |> Jason.encode!()
  end

  defp applied_change_json(%Change{} = c) do
    %{
      repo: to_string(c.repo),
      repo_path: c.repo_path,
      target_app: to_string(c.target_app),
      dependency_source_before: c.dependency_source_before,
      dependency_source_after: c.dependency_source_after,
      mix_exs_before_hash: c.mix_exs_before_hash,
      mix_exs_after_hash: c.proposed_mix_exs_after_hash
    }
  end

  defp change_json(%Change{} = c) do
    %{
      repo: to_string(c.repo),
      repo_path: c.repo_path,
      target_app: to_string(c.target_app),
      dependency_source_before: c.dependency_source_before,
      dependency_source_after: c.dependency_source_after,
      mix_exs_before_hash: c.mix_exs_before_hash,
      proposed_mix_exs_after_hash: c.proposed_mix_exs_after_hash,
      changed: c.changed?
    }
  end
end