lib/graft/status.ex

defmodule Graft.Status do
  @moduledoc """
  Renders a `Graft.Workspace` snapshot as either a human-readable text
  block or a structured JSON string.

  Pure rendering — performs no IO, no filesystem access, no git or mix
  shell-outs, no network calls, and never rescans the workspace. The only
  inputs are the snapshot struct already supplied by the caller.
  """

  alias Graft.{GitRemote, GitState, Workspace}
  alias Graft.Validate.ResultFile
  alias Graft.Validate.ResultFile.Persisted, as: ValidatePersisted
  alias Graft.Workspace.Repo

  @sources [:hex, :path, :git, :unknown]

  @type format :: :text | :json

  @doc """
  Render `workspace` in the chosen format.

  Defaults to `:text`. Returns a binary suitable for printing or piping.
  """
  @spec render(Workspace.t(), format()) :: String.t()
  def render(workspace, format \\ :text)

  def render(%Workspace{} = w, :text), do: render_text(w)
  def render(%Workspace{} = w, :json), do: render_json(w)

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

  defp render_text(%Workspace{} = w) do
    git_by_repo = Map.new(w.git, &{&1.repo, &1})

    header = [
      "Graft workspace",
      "Root: #{w.root}",
      "Repos: #{length(w.repos)}",
      "Validation: #{validation_text(w)}",
      ""
    ]

    repo_blocks =
      Enum.flat_map(w.repos, &repo_text_block(&1, w.deps, Map.get(git_by_repo, &1.name)))

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

  defp repo_text_block(%Repo{} = r, all_deps, git) do
    case repo_status(r) do
      :ok ->
        counts = count_deps(all_deps, r.name)

        [
          to_string(r.name),
          "  status: ok",
          "  git: #{git_text(git)}"
        ] ++
          remote_text_block(r, git) ++
          [
            "  deps: #{format_deps_line(counts)}",
            ""
          ]

      {:missing, msg} ->
        [to_string(r.name), "  status: #{msg}", ""]
    end
  end

  defp git_text(nil), do: "(no git data)"

  defp git_text(%GitState{is_git_repo?: false, error: :git_not_installed}),
    do: "git not installed"

  defp git_text(%GitState{is_git_repo?: false}), do: "not a git repo"

  defp git_text(%GitState{} = g) do
    branch = g.branch || "detached"
    upstream = if g.upstream, do: " ↑ #{g.upstream}", else: " (no upstream)"
    counts = ahead_behind_text(g)
    flags = flag_segments(g)
    base = "#{branch}#{upstream}#{counts}"
    if flags == "", do: base, else: "#{base} #{flags}"
  end

  defp ahead_behind_text(%GitState{ahead: 0, behind: 0}), do: ""
  defp ahead_behind_text(%GitState{ahead: a, behind: 0}), do: " (#{a} ahead)"
  defp ahead_behind_text(%GitState{ahead: 0, behind: b}), do: " (#{b} behind)"
  defp ahead_behind_text(%GitState{ahead: a, behind: b}), do: " (#{a} ahead, #{b} behind)"

  defp flag_segments(%GitState{} = g) do
    [
      if(g.dirty?, do: "dirty", else: "clean"),
      if(g.detached_head?, do: "detached", else: nil),
      if(g.in_progress != :none, do: Atom.to_string(g.in_progress), else: nil)
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.join(", ")
  end

  defp validation_text(%Workspace{validate_result: nil}), do: "(none)"

  defp validation_text(%Workspace{validate_result: %ValidatePersisted{} = p} = w) do
    stale = ResultFile.stale?(w, p)
    base = if p.passed?, do: "✓ passed", else: failed_summary(p)

    targets =
      if p.target_apps == [],
        do: "",
        else: " — #{Enum.map_join(p.target_apps, ",", &Atom.to_string/1)} closure"

    stale_flag = if stale, do: " [stale]", else: ""
    "#{base}#{targets}#{stale_flag}"
  end

  defp failed_summary(%ValidatePersisted{first_failure: nil}), do: "✗ failed"

  defp failed_summary(%ValidatePersisted{first_failure: ff}) do
    "✗ failed at #{ff.command_kind} in #{ff.repo} (#{ff.failure_category})"
  end

  defp format_deps_line(counts) do
    Enum.map_join(@sources, " ", fn s -> "#{s}=#{Map.fetch!(counts, s)}" end)
  end

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

  defp render_json(%Workspace{} = w) do
    git_by_repo = Map.new(w.git, &{&1.repo, &1})

    %{
      root: w.root,
      generated_at: maybe_iso8601(w.generated_at),
      repo_count: length(w.repos),
      validation: validation_json(w),
      repos: Enum.map(w.repos, &repo_json(&1, w.deps, Map.get(git_by_repo, &1.name)))
    }
    |> Jason.encode!()
  end

  defp repo_json(%Repo{} = r, all_deps, git) do
    %{
      name: to_string(r.name),
      exists: r.exists?,
      has_mix_exs: r.has_mix_exs?,
      status: status_string(r),
      origin: origin_json(r, git),
      deps: count_deps(all_deps, r.name),
      git: git_json(git)
    }
  end

  defp validation_json(%Workspace{validate_result: nil}), do: nil

  defp validation_json(%Workspace{validate_result: %ValidatePersisted{} = p} = w) do
    %{
      passed: p.passed?,
      stale: ResultFile.stale?(w, p),
      generated_at: p.generated_at,
      target_apps: Enum.map(p.target_apps, &Atom.to_string/1),
      affected_repos: Enum.map(p.affected_repos, &Atom.to_string/1),
      passed_count: p.passed_count,
      failed_count: p.failed_count,
      skipped_count: p.skipped_count,
      first_failure: encode_first_failure(p.first_failure)
    }
  end

  defp encode_first_failure(nil), do: nil

  defp encode_first_failure(ff) do
    %{
      repo: Atom.to_string(ff.repo),
      command_kind: Atom.to_string(ff.command_kind),
      failure_category: Atom.to_string(ff.failure_category),
      summary: ff.summary
    }
  end

  defp git_json(nil), do: %{is_git_repo: false}

  defp git_json(%GitState{} = g) do
    %{
      is_git_repo: g.is_git_repo?,
      branch: g.branch,
      detached_head: g.detached_head?,
      head_sha: g.head_sha,
      origin_url: g.origin_url,
      upstream: g.upstream,
      ahead: g.ahead,
      behind: g.behind,
      dirty: g.dirty?,
      in_progress: Atom.to_string(g.in_progress),
      error: if(g.error, do: Atom.to_string(g.error), else: nil)
    }
  end

  ## ─── Shared helpers ─────────────────────────────────────────────────

  defp repo_status(%Repo{exists?: false}), do: {:missing, "missing repo"}
  defp repo_status(%Repo{has_mix_exs?: false}), do: {:missing, "missing mix.exs"}
  defp repo_status(_), do: :ok

  defp status_string(repo) do
    case repo_status(repo) do
      :ok -> "ok"
      {:missing, msg} -> msg
    end
  end

  defp remote_text_block(%Repo{origin: nil}, _git), do: []

  defp remote_text_block(%Repo{origin: expected}, %GitState{
         is_git_repo?: true,
         origin_url: actual
       }) do
    if GitRemote.same?(expected, actual) do
      ["  remote: origin ok"]
    else
      ["  remote: mismatch expected #{expected}, actual #{actual || "(none)"}"]
    end
  end

  defp remote_text_block(%Repo{origin: expected}, _git) do
    ["  remote: mismatch expected #{expected}, actual (unavailable)"]
  end

  defp origin_json(%Repo{origin: expected}, git) do
    actual =
      case git do
        %GitState{origin_url: url} -> url
        _ -> nil
      end

    %{
      expected: expected,
      actual: actual,
      matches: if(expected, do: GitRemote.same?(expected, actual), else: nil)
    }
  end

  defp count_deps(all_deps, repo_name) do
    repo_deps = Enum.filter(all_deps, &(&1.repo == repo_name))

    Enum.reduce(@sources, %{}, fn source, acc ->
      Map.put(acc, source, Enum.count(repo_deps, &(&1.source == source)))
    end)
  end

  defp maybe_iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  defp maybe_iso8601(nil), do: nil
end