lib/graft/workspace/dossier.ex

defmodule Graft.Workspace.Dossier do
  @moduledoc """
  Read-only durable workspace status model.

  Summarises one active workspace/session without adding new orchestration
  behaviour. Missing optional data degrades to `:unknown` rather than
  crashing.
  """

  alias Graft.Workspace.HealthIssue

  @type status :: :healthy | :degraded | :unknown | :stale

  @type t :: %__MODULE__{
          workspace_id: String.t() | nil,
          repo_path: Path.t() | nil,
          project_name: String.t() | :unknown | nil,
          branch: String.t() | :unknown | nil,
          worktree_path: Path.t() | nil,
          tmux_session: String.t() | :unknown | nil,
          agent_metadata: map() | :unknown,
          git_summary: map(),
          last_activity_at: DateTime.t() | :unknown,
          health: [HealthIssue.t()] | nil,
          status: status(),
          attention_flags: [atom()],
          generated_at: DateTime.t()
        }

  defstruct [
    :workspace_id,
    :repo_path,
    :project_name,
    :branch,
    :worktree_path,
    :tmux_session,
    :agent_metadata,
    :git_summary,
    :last_activity_at,
    :health,
    :status,
    :attention_flags,
    :generated_at
  ]

  @doc """
  Convert a dossier to a JSON-safe map.
  """
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = d) do
    %{
      "workspace_id" => d.workspace_id,
      "repo_path" => d.repo_path,
      "project_name" => safe_string(d.project_name),
      "branch" => safe_string(d.branch),
      "worktree_path" => d.worktree_path,
      "tmux_session" => safe_string(d.tmux_session),
      "agent_metadata" => safe_map(d.agent_metadata),
      "git_summary" => stringify_keys(d.git_summary),
      "last_activity_at" => safe_datetime(d.last_activity_at),
      "health" => Enum.map(d.health || [], &health_to_map/1),
      "status" => safe_atom(d.status),
      "attention_flags" => Enum.map(d.attention_flags, &Atom.to_string/1),
      "generated_at" => DateTime.to_iso8601(d.generated_at)
    }
  end

  defp safe_string(:unknown), do: "unknown"
  defp safe_string(nil), do: nil
  defp safe_string(val) when is_binary(val), do: val

  defp safe_map(:unknown), do: %{"state" => "unknown"}
  defp safe_map(map) when is_map(map), do: map

  defp safe_datetime(:unknown), do: "unknown"
  defp safe_datetime(nil), do: nil
  defp safe_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)

  defp safe_atom(nil), do: nil
  defp safe_atom(atom) when is_atom(atom), do: Atom.to_string(atom)
  defp safe_atom(val), do: val

  defp stringify_keys(map) when is_map(map) do
    Map.new(map, fn {k, v} -> {to_string(k), v} end)
  end

  defp health_to_map(%HealthIssue{severity: sev, repo: repo, kind: kind, message: msg}) do
    %{
      "severity" => Atom.to_string(sev),
      "repo" => (repo && Atom.to_string(repo)) || nil,
      "kind" => Atom.to_string(kind),
      "message" => msg
    }
  end
end

defmodule Graft.Workspace.Dossier.Builder do
  @moduledoc """
  Boundary module that builds a `Dossier` from an existing
  `Graft.Workspace` snapshot.

  ## Subsystem ownership

  | Field | Source |
  |-------|--------|
  | `workspace_id` | `Workspace.Snapshot` |
  | `repo_path` / `worktree_path` | `Workspace.Snapshot` |
  | `project_name` | Builder (derived from root basename) |
  | `branch` | `GitState` (first git repo) |
  | `tmux_session` | Builder (`TMUX` env var) |
  | `agent_metadata` | Builder (`AGENT_PID` env var) |
  | `git_summary` | `GitState` (aggregated counts) |
  | `last_activity_at` | `Workspace.Snapshot` (`generated_at`) |
  | `health` | `Graft.Verify` / snapshot health list |
  | `status` | Builder (derived from topology + git) |
  | `attention_flags` | Builder (derived from topology + git) |
  | `generated_at` | Builder (`DateTime.utc_now/0`) |
  """

  alias Graft.Workspace
  alias Graft.Workspace.Dossier

  @spec build(Workspace.t()) :: Dossier.t()
  def build(%Workspace{} = snapshot) do
    %Dossier{
      workspace_id: snapshot.id,
      repo_path: snapshot.root,
      project_name: project_name(snapshot),
      branch: primary_branch(snapshot),
      worktree_path: snapshot.root,
      tmux_session: detect_tmux(),
      agent_metadata: detect_agent(),
      git_summary: git_summary(snapshot.git),
      last_activity_at: last_activity(snapshot),
      health: snapshot.health,
      status: derive_status(snapshot),
      attention_flags: derive_attention_flags(snapshot),
      generated_at: DateTime.utc_now()
    }
  end

  ## ─── Field derivation ───────────────────────────────────────────────

  defp project_name(%{root: nil}), do: :unknown

  defp project_name(%{root: root}) do
    root
    |> Path.basename()
    |> String.replace(~r/^workspace[_-]/i, "")
    |> case do
      "" -> :unknown
      name -> name
    end
  end

  defp primary_branch(%{git: []}), do: :unknown

  defp primary_branch(%{git: git}) do
    case Enum.find(git, & &1.is_git_repo?) do
      nil -> :unknown
      gs -> gs.branch || :unknown
    end
  end

  defp detect_tmux do
    case System.get_env("TMUX") do
      nil -> :unknown
      val -> val |> String.split(",") |> List.first() |> Path.basename()
    end
  end

  defp detect_agent do
    case {System.get_env("AGENT_PID"), System.get_env("AGENT_NAME")} do
      {nil, _} -> :unknown
      {pid, name} -> %{pid: pid, name: name || "unknown"}
    end
  end

  defp git_summary(git_states) when is_list(git_states) do
    %{
      repo_count: length(git_states),
      git_repos: Enum.count(git_states, & &1.is_git_repo?),
      dirty_count: Enum.count(git_states, & &1.dirty?),
      detached_count: Enum.count(git_states, & &1.detached_head?),
      ahead_total: Enum.sum(Enum.map(git_states, & &1.ahead)),
      behind_total: Enum.sum(Enum.map(git_states, & &1.behind)),
      in_progress_repos:
        Enum.flat_map(git_states, fn gs ->
          if gs.in_progress != :none and gs.is_git_repo? and gs.repo do
            [Atom.to_string(gs.repo)]
          else
            []
          end
        end)
    }
  end

  defp last_activity(%{generated_at: nil}), do: :unknown
  defp last_activity(%{generated_at: dt}), do: dt

  defp derive_status(%Workspace{topology: %{cyclic?: true}}), do: :degraded

  defp derive_status(%Workspace{git: []}), do: :unknown

  defp derive_status(%Workspace{git: git}) when is_list(git) do
    has_errors = Enum.any?(git, & &1.error)
    has_dirty_progress = Enum.any?(git, &(&1.dirty? and &1.in_progress != :none))

    cond do
      has_errors or has_dirty_progress -> :degraded
      true -> :healthy
    end
  end

  defp derive_status(_), do: :unknown

  defp derive_attention_flags(%Workspace{topology: topology, git: git}) do
    flags = []
    flags = if topology && topology.cyclic?, do: [:cyclic_dependencies | flags], else: flags

    Enum.reduce(git || [], flags, fn gs, acc ->
      acc = if gs.dirty?, do: [:uncommitted_changes | acc], else: acc
      acc = if gs.detached_head?, do: [:detached_head | acc], else: acc
      acc = if gs.ahead > 0 or gs.behind > 0, do: [:branch_divergence | acc], else: acc
      acc = if gs.in_progress != :none, do: [:in_progress | acc], else: acc
      acc = if gs.error, do: [:git_error | acc], else: acc
      acc
    end)
    |> Enum.uniq()
  end
end