lib/foundry/chat/retrieval.ex

defmodule Foundry.Chat.Retrieval do
  @moduledoc """
  Foundry-native retrieval and proposal orchestration for the Studio copilot.

  This keeps discovery inside Foundry first, and only sends compact, relevant
  context down to the provider.
  """

  alias Foundry.Chat.ContextCache
  alias Foundry.Context.ProjectContext
  alias Foundry.SparkMeta.Helpers, as: SparkMetaHelpers

  @max_modules 3
  @max_documents 3

  @spec prepare(String.t(), String.t(), map()) :: {:ok, map()} | {:error, term()}
  def prepare(project_root, message, session_digest) do
    with {:ok, cached_context} <- ContextCache.get_or_build(project_root) do
      modules =
        infer_modules(cached_context.project_context[:nodes] || [], message, session_digest)

      documents = infer_documents(cached_context.project_context[:spec_kit] || %{}, message)

      module_contexts =
        Enum.flat_map(modules, fn module_id ->
          case ProjectContext.build_one(project_root, module_id) do
            {:ok, node} -> [%{id: module_id, summary: summarize_node(node), node: node}]
            {:error, _reason} -> []
          end
        end)

      document_contexts =
        Enum.flat_map(documents, fn doc ->
          case Foundry.FileSystem.read(project_root, doc.path) do
            {:ok, content} ->
              [%{path: doc.path, title: doc.title, type: doc.type, excerpt: excerpt(content)}]

            {:error, _reason} ->
              [%{path: doc.path, title: doc.title, type: doc.type, excerpt: doc.summary}]
          end
        end)

      tool_results = %{
        project_status: summarize_status(cached_context.status),
        system_graph: summarize_graph(cached_context.project_context),
        module_contexts: module_contexts,
        documents: document_contexts,
        proposal_status: summarize_proposal(session_digest),
        scenario_coverage: summarize_scenarios(cached_context),
        retrieval_guidance:
          build_retrieval_guidance(
            project_root,
            modules,
            documents,
            module_contexts,
            document_contexts
          )
      }

      {:ok,
       %{
         cached_context: cached_context,
         tool_results: tool_results,
         trace_events:
           build_tool_trace_events(cached_context, tool_results, message, session_digest)
       }}
    end
  end

  @spec tool_prompt(map()) :: String.t()
  def tool_prompt(%{tool_results: tool_results}) do
    """
    ## Foundry Retrieval Summary

    Treat Project Status, System Architecture, and this Foundry Retrieval Summary
    as already-loaded global context. Reuse them before issuing shell discovery.
    Do not re-fetch `project_status` or `system_graph` in the same turn unless
    the injected context is stale, missing, or you need exact source evidence.
    The system map answers "which"; file or shell reads should answer "what"
    only when the retrieval summary is insufficient. When shell inspection is
    needed, batch related discovery and grouped file reads instead of inspecting
    one file at a time.

    ```json
    #{Jason.encode!(tool_results, pretty: true)}
    ```
    """
  end

  @spec create_proposal(String.t(), String.t(), map(), map(), String.t()) ::
          {:ok, map()} | {:error, term()}
  def create_proposal(message, requester, tool_results, session_digest, project_root) do
    preview = proposal_preview(message, tool_results, project_root)

    attrs = %{
      change_class: classify_change(message, tool_results),
      operation: "Foundry.Studio.ChatProposal",
      operation_params: %{
        "message" => message,
        "session_digest" =>
          Map.take(session_digest || %{}, ["last_proposal_id", "selected_nodes"])
      },
      diff: preview.diff,
      requester: requester,
      adr_link: infer_adr_link(tool_results)
    }

    case Foundry.Proposals.Proposal
         |> Ash.Changeset.for_create(:create_draft, attrs, domain: Foundry.Proposals)
         |> Ash.create() do
      {:ok, proposal} ->
        {:ok,
         %{
           id: proposal.id,
           state: proposal.state,
           change_class: proposal.change_class,
           requester: proposal.requester,
           adr_link: proposal.adr_link,
           operation: proposal.operation,
           preview: preview
         }}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @spec proposal_preview(String.t(), map(), String.t()) :: map()
  def proposal_preview(message, tool_results, project_root) do
    build_proposal_preview(message, tool_results, project_root)
  end

  @spec change_prompt(map()) :: String.t()
  def change_prompt(%{proposal: proposal, tool_results: tool_results}) do
    """
    ## Governed Change Run

    You are acting as a governed proposal author, not a file editor.
    Advance this change by following the three-part sequence below.
    Do not apply files directly. Produce a governed proposal response.

    ```json
    #{Jason.encode!(%{proposal: proposal, retrieval: tool_results}, pretty: true)}
    ```

    ### (a) Spec-kit requirements
    Cite every NodeEntry constraint relevant to this change: ADR references,
    compliance requirement IDs (INV-001..INV-018), runbook obligations, and
    @description field contracts on touched attributes. State any spec-kit artifact
    (ADR draft / runbook stub) that must be produced before code is written.

    ### (b) Igniter operation schema
    Emit the exact Igniter operation payload this proposal should execute, scoped to
    the modules identified in the retrieval results. Use the
    Foundry.Studio.ChatProposal operation format. Do not invent operations outside
    the schema.

    ### (c) Affected modules and test skeletons
    List every module that will be read, written, or deleted. For each written module,
    provide a minimal ExUnit test skeleton derived from the DSL declarations and ADR
    boundary conditions already loaded. Do not write implementation code.

    After completing (a), (b), and (c), state the proposal ID and whether auto-apply
    is permitted based on the change class in the proposal metadata above.
    """
  end

  defp summarize_status(status) do
    %{
      project: status["project"],
      domains: status["domains"],
      lint: status["lint"],
      migrations: status["migrations"],
      proposals: status["proposals"],
      compliance: Map.take(status["compliance"] || %{}, ["total_requirements", "covered_count"]),
      ci: status["ci"]
    }
  end

  defp summarize_graph(project_context) do
    nodes = project_context[:nodes] || []
    edges = project_context[:edges] || []

    %{
      project: project_context[:project],
      node_count: length(nodes),
      edge_count: length(edges),
      domains:
        nodes
        |> Enum.map(& &1.domain)
        |> Enum.reject(&is_nil/1)
        |> Enum.uniq()
        |> Enum.sort()
        |> Enum.take(8)
    }
  end

  defp summarize_node(node) do
    %{
      module: node.module,
      type: node.type,
      domain: node.domain,
      description: node.description,
      sensitive: node.sensitive,
      compliance: node.compliance,
      adrs: node.adrs,
      runbook: node.runbook,
      pending_migrations: node.pending_migrations
    }
  end

  defp summarize_proposal(%{"last_proposal_id" => nil}), do: nil
  defp summarize_proposal(%{"last_proposal_id" => id}) when is_binary(id), do: %{id: id}
  defp summarize_proposal(_digest), do: nil

  defp summarize_scenarios(_cached_context) do
    report = Foundry.Context.ScenarioCache.get()

    if is_nil(report) do
      %{status: :unavailable}
    else
      warnings = report.warnings || []
      coverage = report.coverage || %{}
      uncovered = coverage_uncovered_node_ids(coverage)

      %{
        scenario_count: length(report.scenarios || []),
        warnings: Enum.take(warnings, 5),
        uncovered_node_count: length(uncovered),
        uncovered_nodes: Enum.take(uncovered, 10),
        failing_scenarios:
          report.scenarios
          |> List.wrap()
          |> Enum.filter(&(&1.trace_status == :failed))
          |> Enum.map(&%{id: &1.id, title: &1.title})
          |> Enum.take(10)
      }
    end
  end

  defp build_retrieval_guidance(
         project_root,
         modules,
         documents,
         module_contexts,
         document_contexts
       ) do
    module_files =
      module_contexts
      |> Enum.flat_map(fn module_context ->
        case module_source_path(module_context_module(module_context), project_root) do
          nil -> []
          path -> [Path.relative_to(path, project_root)]
        end
      end)
      |> Enum.uniq()

    document_paths =
      document_contexts
      |> Enum.map(& &1.path)
      |> Enum.uniq()

    file_hints = Enum.take(module_files ++ document_paths, 6)

    %{
      inferred_module_ids: modules,
      inferred_document_paths: Enum.map(documents, & &1.path),
      related_file_hints: file_hints,
      grouped_shell_plan: grouped_shell_plan(modules, file_hints)
    }
  end

  defp grouped_shell_plan([], []) do
    "Reuse the injected retrieval summary first. Do not shell-search for AGENTS.md, module names, or spec-kit paths — these are already in your system prompt. If source evidence is still required, run one grouped discovery command followed by one grouped read command."
  end

  defp grouped_shell_plan(modules, file_hints) do
    module_hint =
      modules
      |> Enum.map(&short_module_name/1)
      |> Enum.join(", ")

    file_hint =
      file_hints
      |> Enum.take(4)
      |> Enum.join(", ")

    [
      "Reuse the injected retrieval summary before any global refetch.",
      "Do not shell-search for AGENTS.md, module names, or spec-kit paths — these are already in your system prompt.",
      if(module_hint != "", do: "Start with grouped module discovery around #{module_hint}."),
      if(file_hint != "",
        do: "If exact source evidence is needed, inspect grouped files such as #{file_hint}."
      ),
      "Prefer one grouped discovery step and one grouped read step over one-file-at-a-time inspection."
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" ")
  end

  defp build_proposal_preview(message, tool_results, project_root) do
    files =
      tool_results
      |> proposal_preview_files(message, project_root)
      |> Enum.take(4)

    graph_overlay = proposal_graph_overlay(tool_results)
    change_summary = Enum.map(files, &Map.fetch!(&1, :summary))

    %{
      summary: proposal_summary(message, tool_results, files),
      change_summary: change_summary,
      diff: build_unified_diff(files, message, tool_results),
      files: files,
      graph_overlay: graph_overlay,
      actions: %{
        apply: true,
        revise: true,
        cancel: true
      }
    }
  end

  defp proposal_preview_files(tool_results, message, project_root) do
    module_files =
      Enum.flat_map(tool_results.module_contexts || [], fn module_context ->
        module = module_context_module(module_context)
        path = module_source_path(module, project_root)
        summary = module_preview_summary(module_context)

        case file_preview_entry(path, :modified, summary, project_root) do
          nil -> []
          file -> [file]
        end
      end)

    document_files =
      Enum.flat_map(tool_results.documents || [], fn document ->
        case file_preview_entry(
               Path.join(project_root, document.path),
               :modified,
               "Refresh #{document.title || document.path} guidance to match this proposal.",
               project_root
             ) do
          nil -> []
          file -> [file]
        end
      end)

    inferred_file =
      inferred_new_file(message, project_root)

    (module_files ++ document_files ++ List.wrap(inferred_file))
    |> Enum.uniq_by(& &1.path)
  end

  defp module_context_module(%{node: %{module: module}}) when is_binary(module), do: module
  defp module_context_module(%{id: id}) when is_binary(id), do: id
  defp module_context_module(_module_context), do: nil

  defp file_preview_entry(nil, _status, _summary, _project_root), do: nil

  defp file_preview_entry(path, status, summary, project_root) do
    with true <- is_binary(path),
         true <- File.exists?(path),
         {:ok, content} <- File.read(path),
         relative_path <- Path.relative_to(path, project_root) do
      diff = build_file_diff(relative_path, content, status)
      {added_lines, removed_lines} = diff_line_counts(diff)

      %{
        path: relative_path,
        status: status,
        summary: summary,
        diff: diff,
        full_content: content,
        added_lines: added_lines,
        removed_lines: removed_lines
      }
    else
      _ -> nil
    end
  end

  defp inferred_new_file(message, _project_root) do
    lowered = String.downcase(message)

    cond do
      String.contains?(lowered, "test") ->
        %{
          path: "test/foundry_web/live/copilot_proposal_preview_test.exs",
          status: :added,
          summary: "Add a focused LiveView test for proposal preview actions and rendering.",
          diff: build_new_file_diff("test/foundry_web/live/copilot_proposal_preview_test.exs"),
          full_content: """
          defmodule FoundryWeb.CopilotProposalPreviewTest do
            use FoundryWeb.ConnCase
          end
          """,
          added_lines: 3,
          removed_lines: 0
        }

      String.contains?(lowered, "copilot") or String.contains?(lowered, "chat") ->
        %{
          path: "apps/foundry_web/lib/foundry_web/live/copilot_proposal_preview.ex",
          status: :added,
          summary: "Add a preview helper module to shape proposal cards for Studio chat.",
          diff:
            build_new_file_diff(
              "apps/foundry_web/lib/foundry_web/live/copilot_proposal_preview.ex"
            ),
          full_content: """
          defmodule FoundryWeb.CopilotProposalPreview do
            @moduledoc false
          end
          """,
          added_lines: 3,
          removed_lines: 0
        }

      true ->
        nil
    end
  end

  defp proposal_summary(_message, tool_results, files) do
    module_names =
      tool_results.module_contexts
      |> Enum.map(&short_module_name(&1.id))
      |> Enum.take(3)

    case {module_names, files} do
      {[], []} ->
        "This proposal captures the requested change and prepares a reviewable diff before any apply step."

      {modules, _} when modules != [] ->
        "This proposal updates #{Enum.join(modules, ", ")} and packages the affected files as a reviewable draft before apply."

      {_, file_entries} ->
        paths = file_entries |> Enum.map(& &1.path) |> Enum.take(3)

        "This proposal stages changes across #{Enum.join(paths, ", ")} and keeps them reviewable before apply."
    end
  end

  defp proposal_graph_overlay(tool_results) do
    modified_nodes =
      Enum.map(tool_results.module_contexts || [], fn module_context ->
        %{
          id: module_context.id,
          label: short_module_name(module_context.id),
          tone: "warning"
        }
      end)

    %{
      nodes_added: [],
      nodes_modified: modified_nodes,
      edges_added: [],
      edges_removed: []
    }
  end

  defp module_preview_summary(module_context) do
    module_name = short_module_name(module_context.id)

    description =
      get_in(module_context, [:summary, :description]) ||
        "Refresh the module behavior and supporting copy."

    "Update #{module_name}: #{description}"
  end

  defp build_unified_diff([], message, tool_results), do: proposal_diff_placeholder(message, tool_results)

  defp build_unified_diff(files, _message, _tool_results) do
    files
    |> Enum.map(& &1.diff)
    |> Enum.join("\n")
  end

  defp build_file_diff(path, content, :modified) do
    preview_lines =
      content
      |> String.split("\n")
      |> Enum.take(8)

    """
    diff --git a/#{path} b/#{path}
    --- a/#{path}
    +++ b/#{path}
    @@
    -#{Enum.at(preview_lines, 0, "")}
    +#{Enum.at(preview_lines, 0, "")}  # proposed change
     #{Enum.at(preview_lines, 1, "")}
     #{Enum.at(preview_lines, 2, "")}
     #{Enum.at(preview_lines, 3, "")}
    """
    |> String.trim_trailing()
  end

  defp build_new_file_diff(path) do
    """
    diff --git a/#{path} b/#{path}
    new file mode 100644
    --- /dev/null
    +++ b/#{path}
    @@
    +# new proposal artifact
    """
    |> String.trim_trailing()
  end

  defp diff_line_counts(diff) do
    lines = String.split(diff || "", "\n")

    {
      Enum.count(lines, &(String.starts_with?(&1, "+") and not String.starts_with?(&1, "+++"))),
      Enum.count(lines, &(String.starts_with?(&1, "-") and not String.starts_with?(&1, "---")))
    }
  end

  defp module_source_path(module, project_root) do
    module
    |> to_module_atom()
    |> case do
      nil -> nil
      atom -> SparkMetaHelpers.module_source_path(atom)
    end
    |> case do
      nil ->
        nil

      path ->
        if is_binary(path) and Path.type(path) == :relative do
          Path.expand(path, project_root)
        else
          path
        end
    end
  end

  defp to_module_atom(module) when is_atom(module), do: module

  defp to_module_atom("Elixir." <> _ = module) do
    try do
      String.to_existing_atom(module)
    rescue
      _ -> nil
    end
  end

  defp to_module_atom(module) when is_binary(module) do
    try do
      String.to_existing_atom("Elixir." <> module)
    rescue
      _ -> nil
    end
  end

  defp to_module_atom(_), do: nil

  defp short_module_name(module_id) when is_binary(module_id) do
    module_id
    |> String.split(".")
    |> List.last()
  end

  defp build_tool_trace_events(cached_context, tool_results, message, session_digest) do
    base = [
      %{
        "provider" => "foundry",
        "type" => "foundry.context",
        "phase" => "context",
        "cache" => Atom.to_string(cached_context.cache),
        "fingerprint" => cached_context.fingerprint,
        "built_at" => cached_context.built_at,
        "message" => "Loaded cached Foundry context"
      },
      %{
        "provider" => "foundry",
        "type" => "foundry.retrieval.summary",
        "phase" => "retrieval",
        "message" => "Prepared cached project status and system graph summary"
      }
    ]

    module_events =
      Enum.map(tool_results.module_contexts, fn module_context ->
        %{
          "provider" => "foundry",
          "type" => "foundry.tool.module_context",
          "phase" => "retrieval",
          "tool" => "module_context",
          "path" => module_context.id,
          "message" => "Loaded module context for #{module_context.id}"
        }
      end)

    document_events =
      Enum.map(tool_results.documents, fn document ->
        %{
          "provider" => "foundry",
          "type" => "foundry.tool.read_doc",
          "phase" => "retrieval",
          "tool" => "read_doc",
          "path" => document.path,
          "message" => "Read spec-kit document #{document.path}"
        }
      end)

    session_event = %{
      "provider" => "foundry",
      "type" => "foundry.session.digest",
      "phase" => "session",
      "message" => "Prepared session digest for this turn",
      "summary" => %{
        "recent_files" => Map.get(session_digest || %{}, "recent_files", []),
        "selected_nodes" => Map.get(session_digest || %{}, "selected_nodes", []),
        "recent_conclusions" => Map.get(session_digest || %{}, "recent_conclusions", []),
        "recent_findings" => Map.get(session_digest || %{}, "recent_findings", []),
        "message_preview" => String.slice(message, 0, 120)
      }
    }

    base ++ module_events ++ document_events ++ [session_event]
  end

  defp infer_modules(nodes, message, session_digest) do
    selected_nodes =
      session_digest
      |> Map.get("selected_nodes", [])
      |> Enum.filter(&is_binary/1)

    selected_matches =
      Enum.filter(nodes, fn node ->
        node.module in selected_nodes or Path.basename(node.module) in selected_nodes
      end)

    token_matches =
      nodes
      |> Enum.map(fn node -> {module_match_score(node, message), node} end)
      |> Enum.filter(fn {score, _node} -> score > 0 end)
      |> Enum.sort_by(fn {score, node} -> {-score, node.module} end)
      |> Enum.map(&elem(&1, 1))

    (selected_matches ++ token_matches)
    |> Enum.uniq_by(& &1.module)
    |> Enum.take(@max_modules)
    |> Enum.map(&trim_elixir_prefix(&1.module))
  end

  defp infer_documents(spec_kit, message) do
    docs =
      Enum.flat_map(["adrs", "runbooks", "findings", "regulations", "usage_rules"], fn key ->
        Map.get(spec_kit, key, [])
      end)

    docs
    |> Enum.map(fn doc -> {document_match_score(doc, message), doc} end)
    |> Enum.filter(fn {score, _doc} -> score > 0 end)
    |> Enum.sort_by(fn {score, doc} -> {-score, doc.path} end)
    |> Enum.take(@max_documents)
    |> Enum.map(&elem(&1, 1))
  end

  defp module_match_score(node, message) do
    haystack =
      [node.module, node.domain, node.description]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" ")
      |> String.downcase()

    message
    |> tokenize()
    |> Enum.count(&String.contains?(haystack, &1))
  end

  defp document_match_score(doc, message) do
    haystack =
      [doc.title, doc.summary, Enum.join(doc.tags || [], " ")]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" ")
      |> String.downcase()

    message
    |> tokenize()
    |> Enum.count(&String.contains?(haystack, &1))
  end

  defp tokenize(text) do
    text
    |> String.downcase()
    |> String.split(~r/[^a-z0-9_]+/, trim: true)
    |> Enum.reject(&(String.length(&1) < 3))
    |> Enum.uniq()
  end

  defp excerpt(content) when is_binary(content) do
    content
    |> String.trim()
    |> String.slice(0, 1000)
  end

  defp classify_change(message, tool_results) do
    text = String.downcase(message)
    modules = tool_results.module_contexts || []

    cond do
      String.contains?(text, ["compliance", "regulation", "adr", "policy"]) ->
        :compliance

      Enum.any?(modules, fn module_context ->
        case module_context do
          %{node: %{sensitive: sensitive}} -> sensitive
          %{node: node} when is_struct(node) -> Map.get(node, :sensitive, false)
          _ -> false
        end
      end) ->
        :sensitive

      String.contains?(text, ["reactor", "rule", "transfer", "job", "workflow", "behavior"]) ->
        :behavioral

      true ->
        :structural
    end
  end

  defp proposal_diff_placeholder(message, tool_results) do
    affected_modules =
      (tool_results[:module_contexts] || [])
      |> Enum.map(& &1.id)
      |> Enum.join(", ")

    """
    Proposal requested from Studio chat.
    Message: #{message}
    Affected modules: #{affected_modules}
    """
  end

  defp infer_adr_link(tool_results) do
    tool_results.documents
    |> Enum.find_value(fn document ->
      if document.type == "adr", do: document.title
    end)
  end

  defp trim_elixir_prefix("Elixir." <> rest), do: rest
  defp trim_elixir_prefix(value), do: value

  defp coverage_uncovered_node_ids(%{uncovered_node_ids: uncovered_node_ids})
       when is_list(uncovered_node_ids),
       do: uncovered_node_ids

  defp coverage_uncovered_node_ids(_coverage), do: []
end