--- passes/components.lua — Component-macro header generator.
---
--- Walks every source for `MipsAtomComp_(ac_X) { body }` 
--- (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations 
--- and emits a per-directory `<dir_basename>.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` 
--- entries for downstream offset computation.
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible.

--- @class Component
--- @field name    string
--- @field body    string
--- @field args    string|nil
--- @field line    integer
--- @field comment string|nil

--- @class M

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

-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD.
dofile((arg[0]:match("(.*[/\\])") or "./") .. "duffle_paths.lua")
local duffle          = require("duffle")
local word_count_eval = require("word_count_eval")

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

-- Atom component declaration identifiers.
local ATOM_COMP_PROC  = "MipsAtomComp_Proc_"
local ATOM_COMP       = "MipsAtomComp_"
local MIPS_ATOM       = "MipsAtom"  -- prefix on the function declaration that wraps an AtomComp_Proc_

-- Component-name prefixes.
local AC_PREFIX       = "ac_"        -- arg to MipsAtomComp_(ac_X); the X is the atom name
local AC_PREFIX_LEN   = 3
local MAC_PREFIX      = "mac_"       -- prefix on generated macros; the rest is the atom name
local MAC_PREFIX_LEN  = 4

-- ASCII byte values used in tokenization.
local BYTE_NEWLINE    = 10
local BYTE_SLASH      = 47

-- Source dir basename used as the output `.macs.h` filename.
local GEN_SUBDIR      = "gen"

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

--- @class SourceFile
--- @field path     string  -- absolute path to the source file
--- @field text     string  -- the full source text
--- @field dir      string  -- the directory containing the source
--- @field basename string  -- filename without extension

--- @class PassCtx
--- @field sources            SourceFile[]           -- all source files in the build
--- @field metadata_path      string                 -- path to word_count.metadata.h
--- @field shared             table                  -- cross-pass shared state
--- @field shared.word_counts table<string, integer> -- populated by word-counts + components
--- @field out_root           string                 -- output root (e.g. "build/gen")
--- @field project_root       string                 -- project root (e.g. "code/")
--- @field upstream           table<string, table>   -- per-pass upstream outputs
--- @field flags              table                  -- CLI flags
--- @field dry_run            boolean                -- if true, compute but don't write
--- @field verbose            boolean                -- if true, log diagnostic info

--- @class PassResult
--- @field outputs  table[]  -- {kind=, path=} entries describing emit files
--- @field errors   table[]  -- {line=, msg=} entries; build-stops
--- @field warnings table[]  -- {line=, msg=} entries; build-succeeds

--- @class Component
--- @field name    string        -- atom name (without `ac_` prefix)
--- @field body    string        -- brace-delimited body (without the braces)
--- @field args    string|nil    -- function-args string (function form only)
--- @field line    integer       -- source line of the declaration
--- @field comment string|nil    -- preceding `/* */` or `//` comment block (signature doc)

-- ════════════════════════════════════════════════════════════════════════════
-- Local helpers (file I/O + path normalization)
-- ════════════════════════════════════════════════════════════════════════════

-- Write content to disk in binary mode so LF line endings are preserved on Windows 
-- (text mode would convert LF -> CRLF, breaking byte-identical diffs against git-tracked gen/*.macs.h files which are stored as LF).
-- @param path string
-- @param content string
local function write_file_lf(path, content)
	local f = io.open(path, "wb")
	if not f then error("Cannot write " .. path) end
	f:write(content); f:close()
end

-- Convert a (possibly relative) path to an absolute Windows path. 
-- The pre-rework output's "// Source:" comment line used the absolute path (e.g. "C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h"); 
-- If we want byte-identical output, we must normalize relative -> absolute before emitting that comment.
-- @param path string
-- @return string
local function to_absolute_path(path)
	if #path >= 2 and path:sub(2, 2) == ":" then
		-- Already absolute; normalize slashes for consistency.
		return (path:gsub("/", "\\"))
	end
	local p = io.popen("cd")
	if not p then return path end
	local cwd = p:read("*l")
	p:close()
	if not cwd then return path end
	-- Normalize forward slashes to backslashes (Windows convention) on
	-- both the cwd AND the relative path tail, so the join is uniform.
	cwd = cwd:gsub("/", "\\")
	local tail = (path:gsub("/", "\\"))
	return cwd .. "\\" .. tail
end

local M = {}

-- ════════════════════════════════════════════════════════════════════════════
-- Function-args extraction (precedes MipsAtomComp_Proc_ invocations)
-- ════════════════════════════════════════════════════════════════════════════

-- Find the LAST occurrence of `name + "("` in `source[1..before_pos]`.
-- Returns the position of the open paren, or nil if not found.
-- @param source string
-- @param name string
-- @param before_pos integer
-- @return integer|nil
local function find_last_name_open_paren(source, name, before_pos)
	local search     = source:sub(1, before_pos)
	local name_open  = name .. "("
	local last_idx   = nil
	local scan_pos   = 1
	while true do
		local found = search:find(name_open, scan_pos, true)  -- plain (no regex)
		if not found then break end
		last_idx = found
		scan_pos = found + #name_open
	end
	return last_idx
end

--- Find the args of the function declaration that immediately precedes
--- a `MipsAtomComp_Proc_` invocation of the given name. Returns the
--- args string (e.g., `"U4 off, U4 code, U1 r, U1 g, U1 b"`) or nil
--- if no function declaration is found.
---
--- Convention: function form is
---   `FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })`
--- We find the LAST occurrence of `"ac_X("` before `before_pos` and
--- extract the args from inside the parens. We then verify the
--- preceding context ends with `MipsAtom` (the function-decl keyword
--- with possible qualifiers between).
---
--- @param source string
--- @param name string
--- @param before_pos integer
--- @return string|nil
local function find_function_args_for(source, name, before_pos)
	local last_idx = find_last_name_open_paren(source, name, before_pos)
	if not last_idx then return nil end

	-- Verify the preceding context ends with "MipsAtom" (with
	-- possible qualifiers between).
	local before   = source:sub(1, last_idx - 1)
	local trimmed  = duffle.trim(before)
	if trimmed:sub(-#MIPS_ATOM) ~= MIPS_ATOM then
		-- Preceding context is not a function declaration.
		return nil
	end

	local open_paren = last_idx + #name  -- position of "("
	local inner      = duffle.read_parens(source, open_paren)
	if not inner then return nil end
	return inner
end

-- ════════════════════════════════════════════════════════════════════════════
-- Preceding-comment-block extraction
-- ════════════════════════════════════════════════════════════════════════════

-- Skip whitespace (space/tab/newline/CR) backward from `pos`, returning
-- the position of the first non-whitespace char.
-- @param source string
-- @param pos integer
-- @return integer
local function skip_ws_backward(source, pos)
	local back = pos - 1
	while back > 0 do
		local ch = source:sub(back, back)
		if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" then
			back = back - 1
		else
			break
		end
	end
	return back
end

-- Find the opening `/*` for a block comment whose `*/` ends at `close_pos`.
-- Returns the position of `/`, or nil if not found.
-- @param source string
-- @param close_pos integer  -- position of the closing `*` of `*/`
-- @return integer|nil
local function find_block_comment_open(source, close_pos)
	local prefix  = source:sub(1, close_pos - 1)
	local open_at = nil
	for scan = #prefix - 1, 1, -1 do
		if prefix:sub(scan, scan + 1) == "/*" then
			open_at = scan
			break
		end
	end
	return open_at
end

-- Walk back from `open_at` over leading spaces + tabs to include the
-- indentation before the `/*` in the captured comment.
-- @param source string
-- @param open_at integer
-- @return integer
local function extend_left_over_indent(source, open_at)
	local start = open_at
	while start > 1 do
		local ch = source:sub(start - 1, start - 1)
		if ch == " " or ch == "\t" then
			start = start - 1
		else
			break
		end
	end
	return start
end

-- Walk back from `line_end` to the start of the source line (the most
-- recent `\n` or position 1).
-- @param source string
-- @param line_end integer
-- @return integer
local function find_line_start(source, line_end)
	local start = line_end
	while start > 1 and source:sub(start - 1, start - 1) ~= "\n" do
		start = start - 1
	end
	return start
end

-- (internal) Capture one `/* ... */` block comment whose closing `*/`
-- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where
-- `new_scan_pos` is where to continue scanning for more comments, or
-- nil if no block comment was found.
local function capture_block_comment(source, close_end_pos)
	local open_at = find_block_comment_open(source, close_end_pos)
	if not open_at then return nil end
	local block_start = extend_left_over_indent(source, open_at)
	return source:sub(block_start, close_end_pos), block_start
end

-- (internal) Capture one `// ...` line comment ending at `line_end_pos`.
-- Returns (comment_text, new_scan_pos) or nil if the line is not a `//` comment.
local function capture_line_comment(source, line_end_pos)
	local line_start = find_line_start(source, line_end_pos)
	local line       = source:sub(line_start, line_end_pos)
	if line:sub(1, 2) == "//" then
		return line, line_start - 1
	end
	return nil
end

--- Find the contiguous comment block immediately preceding `pos` in
--- `source`. Returns the comment text (with the `/* */` or `//` markers
--- preserved) or an empty string if no comment is adjacent.
---
--- Used to copy signature comments from the source declaration
--- (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) over to the
--- generated `mac_X` macro, so LSP/IntelliSense displays the args doc.
---
--- No regex (per the no_regex constraint).
---
--- @param source string
--- @param pos integer
--- @return string
local function preceding_comment_block(source, pos)
	local scan_pos = pos
	local pieces   = {}
	while true do
		local non_ws = skip_ws_backward(source, scan_pos)
		if non_ws == 0 then break end

		local is_block_close = non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/"
		local is_line_end    = source:sub(non_ws, non_ws) == "\n" or source:sub(non_ws, non_ws) == "\r"

		if is_block_close then
			local block_text, new_scan_pos = capture_block_comment(source, non_ws)
			if not block_text then break end
			table.insert(pieces, 1, block_text)
			scan_pos = new_scan_pos
		elseif is_line_end then
			local line_text, new_scan_pos = capture_line_comment(source, non_ws)
			if not line_text then break end
			table.insert(pieces, 1, line_text)
			scan_pos = new_scan_pos
		else
			break
		end
	end
	if #pieces == 0 then return "" end
	return table.concat(pieces, "\n")
end

-- ════════════════════════════════════════════════════════════════════════════
-- Argument-name extraction
-- ════════════════════════════════════════════════════════════════════════════

-- Walk `trimmed` backward from `pos` over trailing whitespace /
-- asterisks / brackets, returning the position of the first
-- non-trailer character (i.e. the end of the identifier).
-- @param trimmed string
-- @param pos integer
-- @return integer
local function trim_trailer_back(trimmed, pos)
	local back = pos
	while back > 0 do
		local ch = trimmed:sub(back, back)
		if ch == " " or ch == "\t" or ch == "*" or ch == "]" or ch == "[" then
			back = back - 1
		else
			break
		end
	end
	return back
end

-- Walk `trimmed` backward from `pos` over identifier chars (alnum + `_`),
-- returning the position just before the identifier starts.
-- @param trimmed string
-- @param pos integer
-- @return integer
local function trim_ident_back(trimmed, pos)
	local back = pos
	while back > 0 do
		local ch = trimmed:sub(back, back)
		if duffle.is_alnum(ch) or ch == "_" then
			back = back - 1
		else
			break
		end
	end
	return back
end

--- Extract just the parameter NAMES from a function-args string
--- (stripping type annotations). E.g.,
---   `"U4 off, U4 code, U1 r, U1 g, U1 b"`  ->  `{"off", "code", "r", "g", "b"}`
---   `"U4 *ptr"`                            ->  `{"ptr"}`
---   `""`                                   ->  nil
---
--- No regex — uses `duffle.is_alnum` + plain string ops.
---
--- @param args_str string|nil
--- @return string[]|nil
local function extract_arg_names(args_str)
	if not args_str or args_str == "" then return nil end
	local names  = {}
	local tokens = duffle.split_top_level_commas(args_str)
	for _, tok in ipairs(tokens) do
		local trimmed = duffle.trim(tok)
		if trimmed ~= "" then
			local ident_end = trim_trailer_back(trimmed, #trimmed)
			local ident_start = trim_ident_back(trimmed, ident_end) + 1
			local name = trimmed:sub(ident_start, ident_end)
			if name ~= "" then names[#names + 1] = name end
		end
	end
	if #names == 0 then return nil end
	return names
end

-- ════════════════════════════════════════════════════════════════════════════
-- Component scanner (bare + function forms)
-- ════════════════════════════════════════════════════════════════════════════

-- Parse the inner content of an `AtomComp_(name, ...)` call. Returns
-- (name, body_or_nil) — `body_or_nil` is non-nil iff this is the
-- function-form `MipsAtomComp_Proc_(name, { body })` invocation.
-- @param inner string  -- the content between ( and ) of the AtomComp_ call
--- @return string|nil, string|nil
local function parse_atomcomp_inner(inner)
	local tokens = duffle.split_top_level_commas(inner)
	if #tokens == 1 then
		return duffle.trim(tokens[1]), nil
	elseif #tokens == 2 then
		local name     = duffle.trim(tokens[1])
		local body_raw = duffle.trim(tokens[2])
		-- Strip leading { and trailing } if present.
		local body
		if #body_raw >= 2 and body_raw:sub(1, 1) == "{" and body_raw:sub(-1) == "}" then
			body = duffle.trim(body_raw:sub(2, -2))
		else
			body = body_raw
		end
		return name, body
	end
	return nil, nil
end

-- (internal) Try to extract a bare-form `MipsAtomComp_(ac_X)` declaration.
-- Bare form: `MipsAtomComp_(ac_X) { body }` — body comes from the brace block
-- AFTER the parens.
-- @param source string
-- @param name string      -- the `ac_X` ident from the parens
--- @param ident_pos integer -- position of the `MipsAtomComp_` ident start
--- @param after_paren integer -- position just past the closing `)`
--- @param line_of fun(pos: integer): integer
--- @param args string|nil  -- function-args from preceding function decl
--- @param comment string   -- preceding comment block
--- @return Component|nil, integer  -- the component + new source position
local function make_bare_component(source, name, ident_pos, after_paren, line_of, args, comment)
	local brace = duffle.scan_to_char(source, "{", after_paren)
	if not brace then return nil, after_paren + 1 end
	local body, after_brace = duffle.read_braces(source, brace)
	return {
		line    = line_of(ident_pos),
		name    = name:sub(AC_PREFIX_LEN + 1),  -- strip "ac_" prefix
		body    = body,
		args    = args,
		comment = comment,
	}, after_brace
end

-- (internal) Build the function-form `MipsAtomComp_Proc_` component. Body
-- came from inside the parens; no following brace block.
local function make_proc_component(name, body, ident_pos, line_of, args, comment)
	return {
		line    = line_of(ident_pos),
		name    = name:sub(AC_PREFIX_LEN + 1),
		body    = body,
		args    = args,
		comment = comment,
	}
end

--- Find every `MipsAtomComp_(ac_<X>) { body }` declaration in source.
--- Supports BOTH the bare form and the function form:
---   Bare:      `MipsAtomComp_(ac_X) { body }`
---   Function:  `MipsAtomComp_Proc_(ac_X, { body })`  (with a preceding
---              `"FI_ MipsAtom ac_X(args)"` function declaration)
---
--- @param source string
--- @return Component[]
local function find_component_atoms(source)
	local line_of = duffle.LineIndex(source)
	local out     = {}
	local pos     = 1
	local src_len = #source
	while pos <= src_len do
		pos = duffle.skip_ws_and_cmt(source, pos)
		if pos > src_len then break end

		local ident, after_ident = duffle.read_ident(source, pos)
		local is_comp = ident == ATOM_COMP or ident == ATOM_COMP_PROC
		if not ident then
			pos = pos + 1
		elseif not is_comp then
			pos = after_ident
		else
			local open_paren = duffle.skip_ws_and_cmt(source, after_ident)
			if source:sub(open_paren, open_paren) ~= "(" then
				pos = open_paren + 1
			else
				local inner, after_paren = duffle.read_parens(source, open_paren)
				local name, body = parse_atomcomp_inner(inner)
				if not name or name:sub(1, AC_PREFIX_LEN) ~= AC_PREFIX then
					pos = open_paren + 1
				else
					local args    = find_function_args_for(source, name, open_paren)
					local comment = preceding_comment_block(source, pos)
					if body == nil then
						-- Bare form: body comes from the brace block after the parens.
						local comp, new_pos = make_bare_component(source, name, pos, after_paren, line_of, args, comment)
						if comp then out[#out + 1] = comp end
						pos = new_pos
					else
						-- Function form: body was inside the parens.
						out[#out + 1] = make_proc_component(name, body, pos, line_of, args, comment)
						pos = after_paren
					end
				end
			end
		end
	end
	return out
end

-- ════════════════════════════════════════════════════════════════════════════
-- Line-comment → block-comment conversion
-- ════════════════════════════════════════════════════════════════════════════

-- Convert `//` line comments to `/* */` block comments in a token.
--
-- C macros use `\` line-continuations; a `//` comment before `\` would
-- consume the continuation, breaking the macro. We convert `//` to
-- `/* */` so the multi-line macro structure is preserved.
--
-- Skips `//` sequences that are inside string or character literals
-- (a rough heuristic — sufficient for component bodies which don't
-- have those constructs).
--
--- @param s string
--- @return string
local function convert_line_comments_to_block(s)
	local result = s
	local pos    = 1
	local len    = #result
	while pos <= len do
		local is_double_slash = result:byte(pos) == BYTE_SLASH
			and pos + 1 <= len and result:byte(pos + 1) == BYTE_SLASH
		if not is_double_slash then
			pos = pos + 1
		else
			-- Find end of line.
			local eol = pos
			while eol <= len and result:byte(eol) ~= BYTE_NEWLINE do
				eol = eol + 1
			end
			local before   = result:sub(1, pos - 1)
			local comment  = result:sub(pos + 2, eol - 1)  -- skip the `//`
			local after
			if eol <= len and result:byte(eol) == BYTE_NEWLINE then
				after = " */" .. result:sub(eol)  -- keep the newline
			else
				after = " */"
			end
			result = before .. "/*" .. comment .. after
			pos = #before + 2 + #comment + 3  -- skip past converted comment
		end
	end
	return result
end

-- ════════════════════════════════════════════════════════════════════════════
-- Word-count computation (memoized recursive lookup)
-- ════════════════════════════════════════════════════════════════════════════

-- Strip the `mac_` prefix from a component-call ident so we can look it
-- up against the components-by-name table. Returns the ident unchanged
-- if it doesn't start with the prefix (so a non-component ident like
-- `mask_upper` falls through to the wc-table branch).
-- @param ident string|nil
-- @return string|nil
local function strip_mac_prefix(ident)
	if not ident then return nil end
	if ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
		return ident:sub(MAC_PREFIX_LEN + 1)
	end
	return ident
end

-- (internal) Recursive word-count lookup. `cache` is the memoization table
-- across all calls to `compute_component_word_count`; the in-progress
-- -1 sentinel detects cycles (A -> B -> A).
-- @param name string             -- the component name (without `mac_`)
-- @param comp_by_name table<string, Component>
-- @param wc table<string, integer>
-- @param cache table<string, integer>
-- @return integer
local function word_count_rec(name, comp_by_name, wc, cache)
	if cache[name] ~= nil then return cache[name] end
	cache[name] = -1  -- mark in-progress (cycle detection)
	local cc = comp_by_name[name]
	local n
	if cc then
		n = 0
		for _, t in ipairs(duffle.split_top_level_commas(cc.body)) do
			local trimmed = duffle.trim(t)
			if trimmed ~= "" then
				local lookup = strip_mac_prefix(duffle.read_ident(trimmed, 1))
				if lookup and comp_by_name[lookup] then
					-- It's a `mac_X(...)` call. Recurse.
					n = n + word_count_rec(lookup, comp_by_name, wc, cache)
				elseif lookup and wc and wc[lookup] then
					-- Encoding macro or pseudo-instruction (e.g. mask_upper = 2, nop2 = 2).
					n = n + wc[lookup]
				else
					-- Unrecognized token. Fall back to 1 word.
					n = n + 1
				end
			end
		end
	else
		-- Not a known component: assume 1 word (regular instruction).
		n = 1
	end
	cache[name] = n
	return n
end

--- Compute the word count of a component body, accounting for macro
--- expansion. Each comma-separated entry in the body is a "slot" that
--- contributes its own word count. For most entries (regular MIPS
--- instructions) the count is 1. For `mac_Y(...)` calls, the count is
--- the word count of `mac_Y` (recursive lookup through `components`).
--- For encoding macros with a known multi-word count (e.g. `mask_upper` = 2),
--- the count is taken from `word_counts`.
---
--- The lookup is memoized via `word_count_rec` to avoid infinite recursion
--- (e.g. if two components referenced each other). This is the same
--- algorithm as the original `tape_atom_annotation_pass.lua` (commit 7d20a4d).
---
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
--- @return integer
local function compute_component_word_count(c, components, wc)
	local comp_by_name = {}
	for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end
	local cache = {}
	return word_count_rec(c.name, comp_by_name, wc, cache)
end

-- ════════════════════════════════════════════════════════════════════════════
-- Per-component emit logic
-- ════════════════════════════════════════════════════════════════════════════

--- Split a (possibly multi-line) comment into per-line entries.
--- Hand-rolled (no regex patterns used).
--- @param s string
--- @return string[]
local function split_comment_lines(s)
	local out  = {}
	local pos  = 1
	local s_len = #s
	while pos <= s_len do
		local nl = s:find("\n", pos, true)
		if not nl then
			out[#out + 1] = s:sub(pos)
			break
		end
		out[#out + 1] = s:sub(pos, nl - 1)
		pos = nl + 1
	end
	return out
end

--- Split an atom body by top-level commas; drop empty tokens.
--- @param body string
--- @return string[]
local function tokens_from_body(body)
	local out = {}
	for _, t in ipairs(duffle.split_top_level_commas(body)) do
		local trimmed = duffle.trim(t)
		if trimmed ~= "" then out[#out + 1] = trimmed end
	end
	return out
end

--- Determine the macro signature: function-args list (function form)
--- or variadic-ignored (bare form).
--- @param args_str string|nil
--- @return string
local function signature_from_args(args_str)
	local arg_names = extract_arg_names(args_str)
	if arg_names and #arg_names > 0 then
		return table.concat(arg_names, ", ")
	end
	return "..."
end

--- Strip the trailing `" \"` (space + backslash) line continuation
--- from the last body line. The last 2 chars are always that pair.
local function strip_trailing_continuation(lines)
	local last = lines[#lines]
	if last:sub(-2) == " \\" then
		lines[#lines] = last:sub(1, -3)
	end
end

--- Emit the `#define mac_X(sig) \<newline>\t<tok1> \<newline>,\t<tok2> ...`
--- block. Converts `//` line comments to `/* */` block comments in
--- each token so they don't break the C macro `\` line continuations.
local function emit_macro_body(lines, c, sig, tokens)
	for tok_idx = 1, #tokens do
		tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx])
	end
	lines[#lines + 1] = "#define mac_" .. c.name .. "(" .. sig .. ") \\"
	lines[#lines + 1] = "\t" .. tokens[1] .. " \\"
	for tok_idx = 2, #tokens do
		lines[#lines + 1] = ",\t" .. tokens[tok_idx] .. " \\"
	end
	strip_trailing_continuation(lines)
end

--- Build the list of lines for one component (signature comment,
--- `#define mac_X(...)` line with backslash-continued tokens, then
--- `WORD_COUNT(mac_X, N)` entry).
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
--- @return string[]  -- list of lines for this component
local function build_component_lines(c, components, wc)
	local lines = {}

	if c.comment and c.comment ~= "" then
		for _, line in ipairs(split_comment_lines(c.comment)) do
			lines[#lines + 1] = line
		end
	end

	local tokens = tokens_from_body(c.body)
	local sig    = signature_from_args(c.args)
	local n      = compute_component_word_count(c, components, wc)

	if n > 0 then
		emit_macro_body(lines, c, sig, tokens)
	end

	-- Emit the WORD_COUNT(mac_<X>, N) entry.
	lines[#lines + 1] = "WORD_COUNT(mac_" .. c.name .. ", " .. n .. ")"
	lines[#lines + 1] = ""

	return lines
end

-- ════════════════════════════════════════════════════════════════════════════
-- Per-source emit logic
-- ════════════════════════════════════════════════════════════════════════════

-- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES`
-- block, the `// Auto-generated` comment, the `// Source:` line, and the
-- self-contained `WORD_COUNT` macro definition).
-- @param src SourceFile
-- @return string[]
local function header_boilerplate(src)
	return {
		-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching
		-- the convention in lottes_tape.h. The build does manual unity
		-- includes (the user controls include order), so the pragma
		-- is only active for IDE/tooling.
		"#ifdef INTELLISENSE_DIRECTIVES",
		"#pragma once",
		"#endif",
		"// Auto-generated by tape_atom_annotation_pass.lua — DO NOT EDIT",
		"// Source: " .. to_absolute_path(src.path),
		"// Component atoms (MipsAtomComp_(ac_*)) -> macro variants (mac_*)",
		"",
		-- Self-contained: define WORD_COUNT if not already defined.
		-- We use the same definition here so the auto-generated
		-- entries below expand to compile-time constants whether
		-- the metadata file is included first or not.
		"#ifndef WORD_COUNT",
		"#define WORD_COUNT(name, count)  enum { words_##name = (count) };",
		"#endif",
		"",
	}
end

-- Compute the output path for one source's `.macs.h` file.
-- The pre-rework convention uses the *directory* basename (not the
-- source file basename) — e.g. `code/duffle/lottes_tape.h` produces
-- `code/duffle/gen/duffle.macs.h`. This matches what the C codebase
-- #includes.
-- @param src SourceFile
-- @return string  -- the output directory
-- @return string  -- the full output path
local function compute_macs_h_path(src)
	local out_dir  = src.dir .. "/" .. GEN_SUBDIR
	local out_path = out_dir .. "/" .. duffle.basename_no_ext(src.dir) .. ".macs.h"
	return out_dir, out_path
end

--- Emit a per-source `.macs.h` header with the `mac_X` macros +
--- `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are
--- preserved (the git blob is LF; Windows text-mode would emit CRLF and
--- break the byte-identical diff).
---
--- Honors `ctx.dry_run`: prints the intended path but does not write
--- the file.
---
--- @param ctx PassCtx
--- @param src SourceFile
--- @param components Component[]
--- @return string|nil  -- path to the written file (nil if no components)
local function emit_component_macros_h(ctx, src, components)
	if #components == 0 then return nil end

	local out_dir, out_path = compute_macs_h_path(src)
	local lines = header_boilerplate(src)

	local wc = ctx.shared.word_counts
	for _, c in ipairs(components) do
		for _, l in ipairs(build_component_lines(c, components, wc)) do
			lines[#lines + 1] = l
		end
	end

	local content = table.concat(lines, "\n") .. "\n"

	if ctx.dry_run then
		print(string.format("  -> %s (dry-run)", out_path))
		return out_path
	end

	duffle.ensure_dir(out_dir)
	write_file_lf(out_path, content)
	print(string.format("  -> %s", out_path))
	return out_path
end

-- ════════════════════════════════════════════════════════════════════════════
-- Pass entry
-- ════════════════════════════════════════════════════════════════════════════

-- (internal) Extend `ctx.shared.word_counts` with this source's component
-- macros so offsets sees them without re-reading the file.
-- @param ctx PassCtx
-- @param components Component[]
local function update_shared_word_counts(ctx, components)
	local wc = ctx.shared.word_counts
	for _, c in ipairs(components) do
		wc["mac_" .. c.name] = compute_component_word_count(c, components, wc)
	end
end

--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
	local outputs  = {}
	local errors   = {}
	local warnings = {}

	for _, src in ipairs(ctx.sources) do
		-- find_component_atoms operates on src.text
		local components = find_component_atoms(src.text)
		if #components > 0 then
			local macs_path = emit_component_macros_h(ctx, src, components)
			if macs_path then
				outputs[#outputs + 1] = { macs_h = macs_path }
				update_shared_word_counts(ctx, components)
			end
		end
	end

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

return M
