--- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms.
---
--- Writer: this pass, given `atom.paths` (the per-atom mutable surface owned by `emission_model`). Readers:
--- `passes/dwarf_injection.lua` (synthesizes DW_TAG_inlined_subroutine + per-word line program rows) and 
--- the gdb-runtime wrapper at `scripts/gdb/gdb_tape_atoms.gdb` (loads the source map via `source <path>`).
---
--- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs: 
---  one `WORD N LINE L TEXT T` line per emitted `.word`, plus the per-word provenance form that DWARF synthesis consumes.
---
--- Two output forms:
---   1. Markdown form: Handled by `passes/report.lua` (writes `<module>.atoms.md`).
---      The render functions `render_source_map` + `render_provenance` are exported for `report.lua` to call directly.
---      Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `<source_dir>/gen/`.
---   2. `gdb_tape_atoms_runtime.gdb`: Post-link opt-in (`ctx.flags.gdb_runtime`),
---      so the gdb wrapper script + the generated runtime script share the same canonical location.
---      Triggered by `--post-link` or `--gdb-runtime`.
---
--- Output forma (sourcemap.txt form):
---   ```
---   # FORMAT_VERSION 1
---   # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT
---   ATOM <name> "<abs-source-path>" <total_words>
---   WORD 0  LINE 49  TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)),
---   WORD 1  LINE 49  TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)),
---   ... (one WORD line per .word emitted by the atom body) ...
---   ENDATOM
---   ATOM <next-name> "<abs-source-path>" <total_words>
---   ...
---   ENDATOM
---   ```
--- Marker records are zero-width in `atom.paths.items`, so they emit no WORD rows in the dense word view.

-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════

-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` 
-- (works both standalone + when require'd). `duffle_paths.lua` sets package.path then returns `require("duffle")` 
-- at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle         = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local elf_dwarf      = require("elf_dwarf")

-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════

-- Format version emitted as the first line. Bump + add a migration test if the format changes; 
-- the gdb runtime loader rejects mismatches (E2).
local FORMAT_VERSION = 1

-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════

--- @class AtomSourceMapCtx
--- @field shared             table   -- `ctx.shared`
--- @field shared.corpus      table   -- source-order registry; single writer is build_ctx
--- @field shared.word_counts table
--- @field out_root           string  -- output root (e.g. "build/gen")
--- @field flags              table   -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path`

-- ════════════════════════════════════════════════════════════════════════════
-- Atom-path renderers
-- ════════════════════════════════════════════════════════════════════════════

--- Join word boundaries (from `items`) to per-word call text + source lines (from `word_events`).
--- @param atom table
--- @return table[], integer
local function canonical_word_entries(atom)
	local paths      = atom.paths or {}
	local events     = paths.word_events or {}
	local word_items = {}
	for _, item in ipairs(paths.items or {}) do
		if item.kind == "word" then word_items[#word_items + 1] = item end
	end

	local entries = {}
	for index, event in ipairs(events) do
		local item = word_items[index] or {}
		entries[#entries + 1] = {
			pos        = event.i or (index - 1),
			line       = event.call_line or item.line or 0,
			text       = event.call_text or item.call_text or "",
			body_line  = event.body_line or item.body_line or item.line or 0,
			invocation = (event.outermost_invocation_id
				and paths.invocations
				and paths.invocations[event.outermost_invocation_id]) or nil,
		}
	end
	return entries, #events
end

--- Render one atom's provenance stanza. Format 1 line shapes:
---   `WORD N  CALL <src-path>:<src-line>  MACRO <name> "<def-path>:<def-line>" BODY <line>` (component invocation)
---   `WORD N  CALL <src-path>:<src-line>  RAW` (raw `.word` outside any mac_* component)
--- Component identity comes from the outermost invocation record; the count-table lookup confirms the component was declared in `corpus.word_counts` 
--- (populated by word_count_eval + components passes).
--- @param src  table
--- @param atom table
--- @param wc   table -- identity alias of corpus.word_counts
--- @return string[], integer
local function emit_provenance_stanza(src, atom, wc)
	local lines          = {}
	local rel_path       = src.path:gsub("\\\\", "/")
	local entries, total = canonical_word_entries(atom)
	lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)

	for _, entry in ipairs(entries) do
		local inv         = entry.invocation
		local macro_count = inv and wc["mac_" .. inv.component_name]
		if inv and macro_count ~= nil then
			lines[#lines + 1] = string.format('WORD %d  CALL %s:%d  MACRO %s "%s:%d" BODY %d'
				, entry.pos, rel_path, entry.line, inv.component_name
				, inv.def_path or "", inv.def_line or 0, entry.body_line)
		else
			lines[#lines + 1] = string.format("WORD %d  CALL %s:%d  RAW", entry.pos, rel_path, entry.line)
		end
	end

	lines[1]          = lines[1]:gsub(" 0$", " " .. tostring(total))
	lines[#lines + 1] = "ENDATOM"
	return lines, total
end

--- Render the full provenance file content for one source.
--- @param src table
--- @param wc  table
--- @return string
local function render_provenance(src, wc)
	local lines = {}
	lines[#lines + 1] = "# FORMAT_VERSION 1"
	lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
	lines[#lines + 1] = "# Per-.word provenance: maps each emitted .word to its call site (atom body"
	lines[#lines + 1] = "# file:line) and, when the word was emitted by a `mac_X(...)` component invocation,"
	lines[#lines + 1] = "# the component's definition file:line + the per-word BODY line. Used by"
	lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word"
	lines[#lines + 1] = "# line program rows for native source-level step into component bodies."

	local function append(atom)
		local stanza = emit_provenance_stanza(src, atom, wc)
		for _, line in ipairs(stanza) do lines[#lines + 1] = line end
	end
	for _, atom in ipairs(src.scan.atoms or {}) do
		if atom.paths then append(atom) end
	end
	for _, atom in ipairs(src.scan.raw_atoms or {}) do
		if atom.paths then append(atom) end
	end

	return table.concat(lines, "\n") .. "\n"
end

--- Render one atom's stanza for the sourcemap.txt form (ATOM header line, N WORD lines, ENDATOM marker).
--- Returns (lines, total_words).
--- @param src    table
--- @param atom   table
--- @param wc     table
--- @return string[], integer
local function emit_atom_stanza(src, atom)
	local lines          = {}
	local rel_path       = src.path:gsub("\\\\", "/")
	local entries, total = canonical_word_entries(atom)

	lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
	for _, entry in ipairs(entries) do
		lines[#lines + 1] = string.format("WORD %d  LINE %d  TEXT %s",
			entry.pos, entry.line, entry.text)
	end
	lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total))
	lines[#lines + 1] = "ENDATOM"
	return lines, total
end

--- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). Mirrors offsets.lua's
--- `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter.
--- @param src table
--- @param wc  table
--- @return string
local function render_source_map(src)
	local lines       = {}
	lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION
	lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"

	local function append(atom)
		local stanza = emit_atom_stanza(src, atom)
		for _, line in ipairs(stanza) do lines[#lines + 1] = line end
	end
	for _, atom in ipairs(src.scan.atoms or {}) do
		if atom.paths then append(atom) end
	end
	for _, atom in ipairs(src.scan.raw_atoms or {}) do
		if atom.paths then append(atom) end
	end

	return table.concat(lines, "\n") .. "\n"
end

-- ════════════════════════════════════════════════════════════════════════════
-- gdb-runtime emission (post-link, addresses via nm)
-- ════════════════════════════════════════════════════════════════════════════

--- Escape a string for embedding in a gdb `set $var = "..."` literal.
--- gdb uses C-style escaping; we escape `\` and `"` (newlines were flattened earlier).
--- @param s string
--- @return string
local function gdb_escape(s)
	return (s:gsub("\\", "\\\\"):gsub('"', '\\"'))
end

--- Build the list of atoms with addresses + word entries. Shared helper for the gdb-runtime file emission.
--- @param ctx PassCtx
--- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries}
local function build_atom_table(ctx)
	local addrs   = elf_dwarf.read_nm(ctx.flags.elf_path)
	local corpus  = ctx.shared and ctx.shared.corpus
	local matched = {}

	for _, src in ipairs(corpus.source_order or {}) do
		local file_base = src.path:match("([^/\\\\]+)$") or src.path
		local function append(atom)
			if not atom.paths then return end
			local  name = atom.raw_name or atom.name
			local  info = addrs[name]
			if not info then return end
			local entries, total = canonical_word_entries(atom)
			matched[#matched + 1] = {
				name       = name,
				src_path   = src.path,
				file_base  = file_base,
				addr       = info[1],
				size_bytes = info[2],
				words      = total,
				entries    = entries,
			}
		end
		for _, atom in ipairs((src.scan or {}).atoms or {}) do append(atom) end
		for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do append(atom) end
	end

	-- Deterministic order: sort by address (matches `nm` output ordering).
	table.sort(matched, function(a, b) return a.addr < b.addr end)
	for i, a in ipairs(matched) do a.idx = i - 1 end
	return matched
end

--- Append the 9 gdb command definitions to `lines`. Pure gdb scripting — addresses come from `nm`,
--- the convenience vars set in `emit_gdb_runtime` provide printf args, and 
--- each command is a static sequence of `printf` / `tbreak` / `if ... end` blocks.
--- The Lua pass emits N atoms' worth of lines; runtime iteration is gdb's job.
---
--- Why hardcoded per-atom: gdb's `$` substitution doesn't concat inside var names — `$__atom_name_$__i` in a `while`
--- loop resolves to one literal identifier, not `name_i`. Compile-time emission is the only path.
--- @param lines   table  -- output line buffer (mutated in place)
--- @param matched table  -- list of atom records from `build_atom_table`
local function append_gdb_commands(lines, matched)
	-- ── tape_atoms ──
	-- Hardcoded one printf per atom. No loop.
	lines[#lines + 1] = "define tape_atoms"
	for _, a in ipairs(matched) do
		-- gdb 12.1 quirk: literals in printf args require an attached target.
		-- Use the per-atom convenience vars set above as printf args.
		lines[#lines + 1] = string.format('  printf "  code_%%-32s @ 0x%%08x  %%4d words\\n", $__atom_name_%d, $__atom_addr_%d, $__atom_words_%d',
			a.idx, a.idx, a.idx)
	end
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document tape_atoms"
	lines[#lines + 1] = "  List every tape atom symbol in the loaded ELF (code_<name>) with .rodata addr + word count."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	-- ── break_atom (generic) + per-atom break_atom_X ──
	lines[#lines + 1] = "define break_atom"
	lines[#lines + 1] = '  echo "Usage: break_atom_<exact_name>  (pick from the list below)"'
	for _, a in ipairs(matched) do
		lines[#lines + 1] = string.format('  printf "  break_atom_%%-32s\\n", $__atom_name_%d', a.idx)
	end
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document break_atom"
	lines[#lines + 1] = "  Generic help: lists the per-atom break_atom_<name> commands."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	for _, a in ipairs(matched) do
		lines[#lines + 1] = string.format("define break_atom_%s", a.name)
		lines[#lines + 1] = string.format("  break *$__atom_addr_%d", a.idx)
		lines[#lines + 1] = string.format('  printf "  Breakpoint set at code_%s (0x%%08x)\\n", $__atom_addr_%d', a.name, a.idx)
		lines[#lines + 1] = "end"
		lines[#lines + 1] = string.format("document break_atom_%s", a.name)
		lines[#lines + 1] = string.format("  Set a breakpoint at code_%s.", a.name)
		lines[#lines + 1] = "end"
		lines[#lines + 1] = ""
	end

	-- ── step_atom / next_atom ──
	-- Hardcoded one tbreak per atom. No loop.
	lines[#lines + 1] = "define step_atom"
	for _, a in ipairs(matched) do
		lines[#lines + 1] = string.format("  tbreak *$__atom_addr_%d", a.idx)
	end
	lines[#lines + 1] = "  continue"
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document step_atom"
	lines[#lines + 1] = "  Set one-shot BPs at every atom + continue. Stops at the next atom boundary."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	lines[#lines + 1] = "define next_atom"
	lines[#lines + 1] = "  step_atom"
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document next_atom"
	lines[#lines + 1] = "  Alias for step_atom."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	-- ── where_in_atom ──
	-- Hardcoded one outer-if per atom; inside, one inner-if per WORD entry.
	lines[#lines + 1] = "define where_in_atom"
	lines[#lines + 1] = "  set $__pc = (unsigned int)$pc"
	lines[#lines + 1] = "  set $__matched = 0"
	for _, a in ipairs(matched) do
		-- Precompute end_addr (gdb 12.1's expression evaluator chokes on `addr + words*4`).
		lines[#lines + 1] = string.format("  set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
		lines[#lines + 1] = string.format("  if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx)
		lines[#lines + 1] = string.format('    printf "atom:    code_%%s\\n", $__atom_name_%d', a.idx)
		lines[#lines + 1] = '    printf "addr:    0x%08x\\n", $__pc'
		lines[#lines + 1] = string.format("    set $__word = ($__pc - $__atom_addr_%d) / 4", a.idx)
		lines[#lines + 1] = string.format('    printf "word:    %%d/%%d\\n", $__word, $__atom_words_%d', a.idx)
		-- One inner-if per WORD entry. Each word's line + text hardcoded.
		for _, we in ipairs(a.entries) do
			lines[#lines + 1] = string.format("    if $__word == %d", we.pos)
			-- Escape TEXT for printf format string.
			local escaped_text = we.text:gsub("%%", "%%%%"):gsub('"', '\\"')
			lines[#lines + 1] = string.format('      printf "source:  %%s:%%d  %%s\\n", $__atom_file_%d, %d, "%s"', a.idx, we.line, escaped_text)
			lines[#lines + 1] = "    end"
		end
		-- Fallback for words beyond the source map (shouldn't happen if nm matches).
		local max_word = 0
		if #a.entries > 0 then max_word = a.entries[#a.entries].pos end
		lines[#lines + 1] = string.format('    if $__word > %d', max_word)
		lines[#lines + 1] = '      printf "source:  (no source-map entry for word %%d; map may be stale)\\n", $__word'
		lines[#lines + 1] = "    end"
		lines[#lines + 1] = "    set $__matched = 1"
		lines[#lines + 1] = "  end"
	end
	lines[#lines + 1] = "  if !$__matched"
	lines[#lines + 1] = '    echo PC is not inside any known atom (in .text or unmapped region).'
	lines[#lines + 1] = "  end"
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document where_in_atom"
	lines[#lines + 1] = "  Report current atom name, .rodata addr, word offset, and source line."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	-- ── stepi_inside_atom ──
	-- Hardcoded one if-containment-check per atom (no loop).
	-- Precompute end_addr in Lua so we don't ask gdb to evaluate `addr + words*4` inside the if condition
	-- (gdb 12.1's expression evaluator chokes on the `*` and emits a misleading 'function malloc' error in some gdb builds).
	lines[#lines + 1] = "define stepi_inside_atom"
	lines[#lines + 1] = "  set $__in_atom = 0"
	lines[#lines + 1] = "  set $__did_step = 0"
	lines[#lines + 1] = "  set $__pc = (unsigned int)$pc"
	for _, a in ipairs(matched) do
		-- Precompute end_addr in the convenience var (single expression gdb handles).
		lines[#lines + 1] = string.format("  set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
		lines[#lines + 1] = string.format("  if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx)
		lines[#lines + 1] = "    set $__in_atom = 1"
		lines[#lines + 1] = "    stepi"
		lines[#lines + 1] = "    set $__did_step = 1"
		lines[#lines + 1] = "  end"
	end
	lines[#lines + 1] = "  if !$__did_step"
	lines[#lines + 1] = '    echo [gdb_tape_atoms] stepi_inside_atom: PC is not inside any atom; refusing to step.'
	lines[#lines + 1] = "  end"
	lines[#lines + 1] = "  where_in_atom"
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document stepi_inside_atom"
	lines[#lines + 1] = "  One MIPS-instruction step, then where_in_atom. The step-and-see-source-line workflow."
	lines[#lines + 1] = "end"
	lines[#lines + 1] = ""

	-- ── wave_ctx ──
	lines[#lines + 1] = "define wave_ctx"
	lines[#lines + 1] = '  printf "$t4 = R_FaceCursor     0x%08x\\n", $t4'
	lines[#lines + 1] = '  printf "$t5 = R_VertBase       0x%08x\\n", $t5'
	lines[#lines + 1] = '  printf "$t6 = R_OtBase         0x%08x\\n", $t6'
	lines[#lines + 1] = '  printf "$t7 = R_PrimCursor     0x%08x\\n", $t7'
	lines[#lines + 1] = "end"
	lines[#lines + 1] = "document wave_ctx"
	lines[#lines + 1] = "  Pretty-print the 4 wave-context GPRs ($t4=R_FaceCursor, $t5=R_VertBase, $t6=R_OtBase, $t7=R_PrimCursor). Requires target attached."
	lines[#lines + 1] = "end"
end

--- Emit the gdb-runtime file (post-link). Pure gdb scripting — addresses come from `mipsel-none-elf-nm -S`, get embedded
--- in `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`, and load via `set $var = ...` + `define ... end` blocks at gdb source-time.
--- @param ctx PassCtx
local function emit_gdb_runtime(ctx)
	if not (ctx.flags and ctx.flags.gdb_runtime) then return end
	local  elf_path = ctx.flags.elf_path
	if not elf_path or elf_path == "" then
		io.stderr:write("[atoms_source_map] --gdb-runtime requires --elf <elf>\n")
		return
	end
	if lfs.attributes(elf_path, "mode") ~= "file" then
		io.stderr:write(string.format(
			"[atoms_source_map] --gdb-runtime: ELF not found at %s\n", elf_path))
		return
	end

	local matched = build_atom_table(ctx)
	if #matched == 0 then
		io.stderr:write("[atoms_source_map] --gdb-runtime: no atoms matched against nm symbols (stale scan?).\n")
		return
	end

	local lines = {}
	lines[#lines + 1] = "# Auto-generated by ps1_meta.lua (passes/atoms_source_map.lua)"
	lines[#lines + 1] = "# DO NOT EDIT — re-run ps1_meta.lua --atoms-source-map --gdb-runtime to regenerate"
	lines[#lines + 1] = "# Sourced by scripts/gdb/gdb_tape_atoms.gdb (the wrapper)."
	lines[#lines + 1] = "# Pure gdb scripting — no Python, no Tcl, no Guile required."
	lines[#lines + 1] = "# Commands are FULLY HARDCODED per-atom because gdb doesn't do nested"
	lines[#lines + 1] = "# `$` substitution in var names (`$foo_$i` is one literal identifier)."
	lines[#lines + 1] = "# Per-atom convenience vars ($__atom_name_<i> etc.) are set so gdb's"
	lines[#lines + 1] = "# `printf` has valid expression args (gdb 12.1 quirks: literals in"
	lines[#lines + 1] = "# printf args require an attached target; convenience-var args do not)."
	lines[#lines + 1] = string.format("# %d atoms from ELF: %s", #matched, elf_path)
	lines[#lines + 1] = ""

	-- Format version + count + ELF path (the latter is referenced by the load-line).
	lines[#lines + 1] = "set $__atom_format_version = " .. FORMAT_VERSION
	lines[#lines + 1] = string.format("set $__atom_count = %d", #matched)
	lines[#lines + 1] = string.format('set $__elf_path = "%s"', gdb_escape(elf_path))
	lines[#lines + 1] = ""

	-- Per-atom convenience vars (used as printf args; literals aren't accepted
	-- without an attached target on gdb 12.1).
	for _, a in ipairs(matched) do
		lines[#lines + 1] = string.format('set $__atom_name_%d = "%s"', a.idx, gdb_escape(a.name))
		lines[#lines + 1] = string.format("set $__atom_addr_%d = 0x%x", a.idx, a.addr)
		lines[#lines + 1] = string.format("set $__atom_words_%d = %d", a.idx, a.words)
		lines[#lines + 1] = string.format('set $__atom_file_%d = "%s"', a.idx, gdb_escape(a.file_base))
	end
	lines[#lines + 1] = ""

	-- The 9 commands (each `define ... end` overrides the wrapper's stub).
	lines[#lines + 1] = "# ── 9 user commands (overrides wrapper stubs) ──"
	append_gdb_commands(lines, matched)
	lines[#lines + 1] = ""

	-- Confirmation line for the source operator.
	lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path'

	local out_path
	-- Move out of `<out_root>/gdb_tape_atoms_runtime.gdb` to `<out_root>/../gdb_tape_atoms_runtime.gdb` when the conventional `<out_root>` is `<build>/gen`
	-- (any equivalent spelling — relative, absolute backslash, absolute forward-slash, trailing-separator variants).
	-- This puts the gdb runtime alongside the ELF at `build/` rather than under the report subdir.
	local function ends_with_gen_dir(p)
		if type(p) ~= "string" then return false end
		return p:match("[/\\]gen[/\\]?$") ~= nil or p == "build/gen" or p == "build\\gen"
	end
	if ends_with_gen_dir(ctx.out_root) then
		-- Strip the trailing `/gen` segment, then write the runtime script under `build/`.
		-- e.g. "C:/projects/Pikuma/ps1/build/gen" -> "C:/projects/Pikuma/ps1/build".
		local parent = ctx.out_root:gsub("[/\\]gen[/\\]?$", "")
		out_path = parent .. "/gdb_tape_atoms_runtime.gdb"
	else
		out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb"
	end
	duffle.ensure_dir(duffle.dirname(out_path))
	duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
	-- io.stderr:write(string.format("[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched))
end

-- ════════════════════════════════════════════════════════════════════════════
-- M — module exports
-- ════════════════════════════════════════════════════════════════════════════

local M = {}

-- Expose the pure render functions so `report.lua` and the focused tests can call them directly without triggering the file-emit path.
M.render_source_map = render_source_map
M.render_provenance = render_provenance

--- Render ONE atom's sourcemap stanza.
--- @param atom table   -- atom record (must have `atom.paths` populated)
--- @return string
function M.render_atom_source_map(atom)
	assert(type(atom) == "table", "render_atom_source_map: atom must be a table")
	assert(type(atom.paths) == "table", "render_atom_source_map: atom.paths must be a table")
	local entries, total = canonical_word_entries(atom)
	local lines = {}
	lines[#lines + 1] = string.format("ATOM %s  %d", (atom.raw_name or atom.name), total)
	for _, entry in ipairs(entries) do
		lines[#lines + 1] = string.format("WORD %d  LINE %d  TEXT %s",
			entry.pos, entry.line, entry.text)
	end
	lines[#lines + 1] = "ENDATOM"
	return table.concat(lines, "\n") .. "\n"
end

--- Render ONE atom's provenance stanza — no per-file format header, no enumeration of other atoms.
---
--- `rel_path` is the source path (forward-slashes) embedded in every `CALL` line.
--- The .md caller (report.lua) is expected to derive this once per `## <source>` heading and pass it down for each atom in that source.
--- @param atom      table   -- atom record (must have `atom.paths` populated)
--- @param wc        table   -- identity alias of `corpus.word_counts`
--- @param rel_path  string  -- source path (forward-slashes) for `CALL` fields
--- @return string
function M.render_atom_provenance(atom, wc, rel_path)
	assert(type(atom)       == "table", "render_atom_provenance:  atom must be a table")
	assert(type(atom.paths) == "table", "render_atom_provenance:  atom.paths must be a table")
	assert(type(rel_path)   == "string", "render_atom_provenance: rel_path must be a string")
	local entries, total = canonical_word_entries(atom)
	local lines = {}
	lines[#lines + 1] = string.format("ATOM %s  %d", (atom.raw_name or atom.name), total)
	for _, entry in ipairs(entries) do
		local inv = entry.invocation
		local macro_count = inv and wc and wc["mac_" .. inv.component_name]
		if inv and macro_count ~= nil then
			lines[#lines + 1] = string.format(
				'WORD %d  CALL %s:%d  MACRO %s "%s:%d" BODY %d',
				entry.pos, rel_path, entry.line, inv.component_name,
				inv.def_path or "", inv.def_line or 0, entry.body_line)
		else
			lines[#lines + 1] = string.format(
				"WORD %d  CALL %s:%d  RAW", entry.pos, rel_path, entry.line)
		end
	end
	return table.concat(lines, "\n") .. "\n"
end

--- Pass entry. For each source that declares at least one `MipsAtom_(name)` / `MipsCode code_<name>`,
--- emit two files in `<out_root>/`: `<basename>.atoms.sourcemap.txt` (per-word call-site map) and `<basename>.atoms.provenance.txt`
--- (per-word definition + body line, resolved via the outermost `mac_X(...)` invocation).
--- When `ctx.flags.gdb_runtime` is true and `ctx.flags.elf_path` exists, also emit the post-link gdb script `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`.
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
	local outputs  = {}
	local errors   = {}
	local warnings = {}

	local corpus = ctx.shared and ctx.shared.corpus
	if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then
		error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
	end

	-- Word counts come from `corpus.word_counts` (populated by word_count_eval + components passes).
	local wc = corpus.word_counts or {}
	if not next(wc) then
		warnings[#warnings + 1] = {
			line = 0,
			msg  = "atoms_source_map: corpus.word_counts is empty; the word-counts + components passes may not have populated it. Check the PASSES dep edges.",
		}
	end

	-- atoms.sourcemap.txt + atoms.provenance.txt content moved to report.lua via `<module>.atoms.md` markdown file.
	-- This pass emits only the post-link gdb_runtime artifact (see emit_gdb_runtime below).

	-- Optionally emit the gdb-runtime form (post-link, one file per build).
	if ctx.flags and ctx.flags.gdb_runtime then
		emit_gdb_runtime(ctx)
	end

	return { outputs = outputs, errors = errors, warnings = warnings }
end

return M
