lib/graft/workspace/serializer.ex

defmodule Graft.Workspace.Serializer do
  @moduledoc """
  Serializes and deserializes `Graft.Workspace` snapshots.

  Every snapshot is versioned (`schema_version`). The serializer handles
  migration between schema versions transparently on load.

  ## Contract

  * Snapshots encode to deterministic JSON — same bytes for same logical state.
  * Atoms become strings, paths become strings, DateTimes become ISO8601.
  * Unknown fields on decode are preserved in a `__extra__` map for forward
    compatibility (future schema versions can read old snapshots).
  * Decode failures return structured `Graft.Error{}`, not exceptions.
  """

  alias Graft.{Error, Workspace}
  alias Graft.Workspace.{Repo, Dependency, Link, Topology}
  alias Graft.GitState

  @current_schema_version 1

  @doc """
  Encode a workspace snapshot to a JSON string.
  """
  @spec to_json(Workspace.t()) :: String.t()
  def to_json(%Workspace{} = snapshot) do
    snapshot
    |> to_map()
    |> Jason.encode!(pretty: true)
    |> Kernel.<>("\n")
  end

  @doc """
  Decode a JSON string into a workspace snapshot.

  Handles schema migration. Returns `{:ok, snapshot}` or `{:error, reason}`.
  """
  @spec from_json(String.t()) :: {:ok, Workspace.t()} | {:error, Error.t()}
  def from_json(json) when is_binary(json) do
    with {:ok, raw_map} <- decode_json(json),
         {:ok, version} <- extract_schema_version(raw_map),
         {:ok, migrated} <- migrate_to_current(raw_map, version) do
      build_snapshot(migrated)
    end
  end

  @doc """
  Persist a snapshot to disk atomically.

  Writes to a temp file and renames — POSIX-atomic within a filesystem.
  """
  @spec save(Path.t(), Workspace.t()) :: :ok | {:error, Error.t()}
  def save(path, %Workspace{} = snapshot) do
    json = to_json(snapshot)
    tmp = path <> ".tmp"

    with :ok <- File.mkdir_p(Path.dirname(path)),
         :ok <- File.write(tmp, json),
         :ok <- File.rename(tmp, path) do
      :ok
    else
      {:error, reason} ->
        File.rm(tmp)

        {:error,
         Error.new(
           :workspace_io_error,
           "Failed to write snapshot to #{path}: #{:file.format_error(reason)}",
           %{path: path, reason: reason}
         )}
    end
  end

  @doc """
  Load a snapshot from disk.
  """
  @spec load(Path.t()) :: {:ok, Workspace.t()} | {:error, Error.t()}
  def load(path) when is_binary(path) do
    case File.read(path) do
      {:ok, json} ->
        from_json(json)

      {:error, reason} ->
        {:error,
         Error.new(
           :workspace_io_error,
           "Failed to read snapshot from #{path}: #{:file.format_error(reason)}",
           %{path: path, reason: reason}
         )}
    end
  end

  ## ─── Encoding ───────────────────────────────────────────────────────

  defp to_map(%Workspace{} = ws) do
    %{
      "id" => ws.id,
      "schema_version" => ws.schema_version,
      "root" => ws.root,
      "generated_at" => DateTime.to_iso8601(ws.generated_at),
      "repos" => Enum.map(ws.repos, &repo_to_map/1),
      "deps" => Enum.map(ws.deps, &dep_to_map/1),
      "links" => Enum.map(ws.links, &link_to_map/1),
      "topology" => topology_to_map(ws.topology),
      "git" => Enum.map(ws.git, &git_state_to_map/1),
      "prs" => Enum.map(ws.prs, &pr_to_map/1),
      "health" => Enum.map(ws.health, &health_to_map/1),
      "diagnostics" => Enum.map(ws.diagnostics, &diagnostic_to_map/1),
      "validate_result" => validate_result_to_map(ws.validate_result)
    }
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Enum.into(%{})
  end

  defp repo_to_map(%Repo{} = r) do
    %{
      "name" => Atom.to_string(r.name),
      "path" => r.path,
      "absolute_path" => r.absolute_path,
      "exists?" => r.exists?,
      "has_mix_exs?" => r.has_mix_exs?
    }
  end

  defp dep_to_map(%Dependency{} = d) do
    %{
      "repo" => Atom.to_string(d.repo),
      "app" => Atom.to_string(d.app),
      "raw" => d.raw,
      "source" => Atom.to_string(d.source)
    }
  end

  defp link_to_map(%Link{} = l) do
    %{
      "repo" => Atom.to_string(l.repo),
      "dep" => Atom.to_string(l.dep),
      "mix_exs_sha256_before" => l.mix_exs_sha256_before,
      "mix_exs_sha256_after" => l.mix_exs_sha256_after,
      "preimage" => l.preimage,
      "replacement" => l.replacement
    }
  end

  defp topology_to_map(nil), do: nil

  defp topology_to_map(%Topology{} = t) do
    %{
      "consumers" => map_atom_keys_to_strings(t.consumers),
      "providers" => map_atom_keys_to_strings(t.providers),
      "transitive_dependents" =>
        Map.new(t.transitive_dependents, fn {k, v} ->
          {Atom.to_string(k), Enum.to_list(v)}
        end),
      "topological_order" => Enum.map(t.topological_order, &Atom.to_string/1),
      "external_apps" => Enum.map(t.external_apps, &Atom.to_string/1),
      "cyclic?" => t.cyclic?
    }
  end

  defp git_state_to_map(%GitState{} = g) do
    %{
      "repo" => if(g.repo, do: Atom.to_string(g.repo), else: nil),
      "repo_path" => g.repo_path,
      "is_git_repo?" => g.is_git_repo?,
      "branch" => g.branch,
      "detached_head?" => g.detached_head?,
      "head_sha" => g.head_sha,
      "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

  # TODO when PR type stabilizes
  defp pr_to_map(_pr), do: %{}
  # TODO when HealthIssue type stabilizes
  defp health_to_map(_h), do: %{}
  # TODO when Diagnostic type stabilizes
  defp diagnostic_to_map(_d), do: %{}
  defp validate_result_to_map(nil), do: nil
  # Placeholder
  defp validate_result_to_map(vr), do: Jason.encode!(vr)

  defp map_atom_keys_to_strings(map) do
    Map.new(map, fn {k, v} -> {Atom.to_string(k), v} end)
  end

  ## ─── Decoding ───────────────────────────────────────────────────────

  defp decode_json(json) do
    case Jason.decode(json) do
      {:ok, map} ->
        {:ok, map}

      {:error, e} ->
        {:error,
         Error.new(
           :workspace_invalid_json,
           "Failed to parse workspace snapshot: #{Exception.message(e)}"
         )}
    end
  end

  defp extract_schema_version(map) when is_map(map) do
    case Map.fetch(map, "schema_version") do
      {:ok, v} when is_integer(v) and v > 0 ->
        {:ok, v}

      {:ok, v} ->
        {:error,
         Error.new(
           :workspace_invalid_schema,
           "Invalid schema version: #{inspect(v)}",
           %{schema_version: v}
         )}

      :error ->
        {:error,
         Error.new(
           :workspace_invalid_schema,
           "Missing schema_version field"
         )}
    end
  end

  defp migrate_to_current(map, version) when version == @current_schema_version do
    {:ok, map}
  end

  defp migrate_to_current(_map, version) do
    {:error,
     Error.new(
       :workspace_unsupported_schema,
       "Schema version #{version} is not supported (current: #{@current_schema_version}). " <>
         "Migration path not yet implemented.",
       %{version: version, current: @current_schema_version}
     )}
  end

  defp build_snapshot(map) when is_map(map) do
    with {:ok, id} <- fetch_string(map, "id"),
         {:ok, root} <- fetch_string(map, "root"),
         {:ok, generated_at} <- fetch_datetime(map, "generated_at"),
         {:ok, repos} <- fetch_list_of(map, "repos", &build_repo/1),
         {:ok, deps} <- fetch_list_of(map, "deps", &build_dep/1),
         {:ok, topology} <- fetch_optional(map, "topology", &build_topology/1),
         {:ok, git} <- fetch_list_of(map, "git", &build_git_state/1) do
      {:ok,
       %Workspace{
         id: id,
         schema_version: @current_schema_version,
         root: root,
         generated_at: generated_at,
         repos: repos,
         deps: deps,
         topology: topology,
         git: git
         # Other fields are defaults
       }}
    end
  end

  ## ─── Field helpers ────────────────────────────────────────────────

  defp fetch_string(map, key) do
    case Map.fetch(map, key) do
      {:ok, s} when is_binary(s) ->
        {:ok, s}

      {:ok, other} ->
        {:error,
         Error.new(:workspace_invalid_field, "#{key} must be a string, got: #{inspect(other)}")}

      :error ->
        {:error, Error.new(:workspace_invalid_field, "Missing required field: #{key}")}
    end
  end

  defp fetch_datetime(map, key) do
    with {:ok, s} <- fetch_string(map, key) do
      case DateTime.from_iso8601(s) do
        {:ok, dt, _} ->
          {:ok, dt}

        {:error, _} ->
          {:error,
           Error.new(:workspace_invalid_field, "#{key} is not a valid ISO8601 datetime: #{s}")}
      end
    end
  end

  defp fetch_list_of(map, key, builder) do
    case Map.fetch(map, key) do
      {:ok, list} when is_list(list) ->
        Enum.reduce_while(list, {:ok, []}, fn item, {:ok, acc} ->
          case builder.(item) do
            {:ok, built} -> {:cont, {:ok, [built | acc]}}
            {:error, _} = err -> {:halt, err}
          end
        end)
        |> case do
          {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
          err -> err
        end

      {:ok, other} ->
        {:error,
         Error.new(:workspace_invalid_field, "#{key} must be a list, got: #{inspect(other)}")}

      :error ->
        # Optional lists default to empty
        {:ok, []}
    end
  end

  defp fetch_optional(map, key, builder) do
    case Map.fetch(map, key) do
      {:ok, nil} -> {:ok, nil}
      {:ok, value} -> builder.(value)
      :error -> {:ok, nil}
    end
  end

  ## ─── Builders ───────────────────────────────────────────────────────

  defp build_repo(map) when is_map(map) do
    with {:ok, name} <- fetch_atom(map, "name"),
         {:ok, path} <- fetch_string(map, "path"),
         {:ok, absolute_path} <- fetch_string(map, "absolute_path"),
         exists? <- Map.get(map, "exists?", false),
         has_mix_exs? <- Map.get(map, "has_mix_exs?", false) do
      {:ok,
       %Repo{
         name: name,
         path: path,
         absolute_path: absolute_path,
         exists?: exists?,
         has_mix_exs?: has_mix_exs?
       }}
    end
  end

  defp build_dep(map) when is_map(map) do
    with {:ok, repo} <- fetch_atom(map, "repo"),
         {:ok, app} <- fetch_atom(map, "app"),
         {:ok, raw} <- fetch_string(map, "raw"),
         {:ok, source} <- fetch_atom(map, "source") do
      {:ok,
       %Dependency{
         repo: repo,
         app: app,
         raw: raw,
         source: source
       }}
    end
  end

  defp build_topology(map) when is_map(map) do
    {:ok,
     %Topology{
       consumers: map_string_keys_to_atoms(Map.get(map, "consumers", %{})),
       providers: map_string_keys_to_atoms(Map.get(map, "providers", %{})),
       transitive_dependents:
         Map.new(Map.get(map, "transitive_dependents", %{}), fn {k, v} ->
           {String.to_existing_atom(k), MapSet.new(v)}
         end),
       topological_order:
         Map.get(map, "topological_order", [])
         |> Enum.map(&String.to_existing_atom/1),
       external_apps:
         Map.get(map, "external_apps", [])
         |> Enum.map(&String.to_existing_atom/1),
       cyclic?: Map.get(map, "cyclic?", false)
     }}
  rescue
    ArgumentError ->
      {:error,
       Error.new(
         :workspace_invalid_field,
         "Topology references unknown atoms — load workspace before snapshot"
       )}
  end

  defp build_git_state(map) when is_map(map) do
    {:ok,
     %GitState{
       repo: map |> Map.get("repo") |> maybe_atom(),
       repo_path: Map.get(map, "repo_path"),
       is_git_repo?: Map.get(map, "is_git_repo?", false),
       branch: Map.get(map, "branch"),
       detached_head?: Map.get(map, "detached_head?", false),
       head_sha: Map.get(map, "head_sha"),
       upstream: Map.get(map, "upstream"),
       ahead: Map.get(map, "ahead", 0),
       behind: Map.get(map, "behind", 0),
       dirty?: Map.get(map, "dirty?", false),
       in_progress:
         map
         |> Map.get("in_progress", "none")
         |> String.to_existing_atom(),
       error:
         map
         |> Map.get("error")
         |> maybe_existing_atom()
     }}
  rescue
    ArgumentError ->
      {:error, Error.new(:workspace_invalid_field, "GitState references unknown atoms")}
  end

  ## ─── Atom helpers ───────────────────────────────────────────────────

  defp fetch_atom(map, key) do
    with {:ok, s} <- fetch_string(map, key) do
      try do
        {:ok, String.to_existing_atom(s)}
      rescue
        ArgumentError ->
          {:error,
           Error.new(
             :workspace_unknown_atom,
             "Field #{key} references unknown atom: #{s}. Load workspace before snapshot.",
             %{key: key, value: s}
           )}
      end
    end
  end

  defp maybe_atom(nil), do: nil
  defp maybe_atom(s) when is_binary(s), do: String.to_atom(s)

  defp maybe_existing_atom(nil), do: nil

  defp maybe_existing_atom(s) when is_binary(s) do
    try do
      String.to_existing_atom(s)
    rescue
      ArgumentError -> nil
    end
  end

  defp map_string_keys_to_atoms(map) when is_map(map) do
    Map.new(map, fn {k, v} ->
      {String.to_existing_atom(k), v}
    end)
  end
end