--- Result statuses emitted for test, hook, and synthetic records.
--- @alias test.harness.ResultStatus
---| 'success'
---| 'pending'
---| 'failure'
---| 'error'

--- Hook phases supported by the chunk environment.
--- @alias test.harness.HookKind
---| 'setup'
---| 'teardown'
---| 'before_each'
---| 'after_each'

--- Execution scopes that can run under harness error handling.
--- @alias test.harness.ExecutionScope
---| 'test'
---| 'setup'
---| 'teardown'
---| 'before_each'
---| 'after_each'
---| 'suite_end'

--- Source location recorded for defined suites, tests, and hooks.
--- @class test.harness.Trace
--- @field short_src string
--- @field currentline integer

--- Structured error payload used internally by the harness.
--- @class test.harness.ErrorPayload
--- @field __harness_pending? boolean
--- @field message string
--- @field trace? test.harness.Trace
--- @field traceback? string

--- @alias test.harness.Element
--- | test.harness.Suite
--- | test.harness.Test

--- Base node shared by suite and test definitions.
--- @class test.harness.ElementBase
--- @field name string
--- @field parent? test.harness.Suite
--- @field trace? test.harness.Trace
--- @field duration? number
--- @field full_name? string

--- Registered callback together with the location where it was registered.
--- @class test.harness.RegisteredCallback
--- @field fn fun()
--- @field trace? test.harness.Trace

--- Hook callbacks grouped by phase.
--- @class test.harness.HookSet
--- @field setup test.harness.RegisteredCallback[]
--- @field teardown test.harness.RegisteredCallback[]
--- @field before_each test.harness.RegisteredCallback[]
--- @field after_each test.harness.RegisteredCallback[]

--- Suite node containing hooks and nested children.
--- @class test.harness.Suite : test.harness.ElementBase
--- @field kind 'suite'
--- @field is_file boolean
--- @field children test.harness.Element[]
--- @field selected_count integer
--- @field hooks test.harness.HookSet

--- Test node containing an optional runnable body.
--- @class test.harness.Test : test.harness.ElementBase
--- @field kind 'test'
--- @field fn? fun()
--- @field parent test.harness.Suite
--- @field pending_message? string
--- @field selected? boolean

--- Normalized result returned from running a test or hook.
--- @class test.harness.Result
--- @field status test.harness.ResultStatus
--- @field message? string
--- @field traceback? string
--- @field trace? test.harness.Trace

--- Collected test file path plus its display label.
--- @class test.harness.FileEntry
--- @field path string
--- @field display_name string

--- Shallow process baseline restored between test files.
--- Mutable tables intentionally preserve identity here; deeper isolation
--- requires separate processes rather than table cloning inside one Lua state.
--- @class test.harness.RuntimeBaseline
--- @field cwd string
--- @field package_path string
--- @field package_cpath string
--- @field package_preload table<string, any>
--- @field globals table<any, any>
--- @field loaded table<string, any>
--- @field env table<string, string>
--- @field arg table<integer, string>

--- Parsed CLI options controlling one harness run.
--- @class test.harness.Options
--- @field keep_going boolean
--- @field verbose boolean
--- @field repeat_count integer
--- @field summary_file string
--- @field helper? string
--- @field tags string[]
--- @field filter? string
--- @field filter_out string[]
--- @field lpaths string[]
--- @field cpaths string[]
--- @field paths string[]

--- Stored suite-end callback together with its registration site.
--- @class test.harness.SuiteEndRegistration : test.harness.RegisteredCallback
--- @field key string

--- Active execution context for one running hook or test.
--- @class test.harness.Execution
--- @field scope test.harness.ExecutionScope
--- @field finalizers test.harness.RegisteredCallback[]

--- Mutable harness state shared across definition and execution.
--- @class test.harness.State
--- @field suite_end_callbacks test.harness.SuiteEndRegistration[]
--- @field current_define_suite? test.harness.Suite
--- @field current_execution? test.harness.Execution

local uv = vim.uv

--- Public test harness module surface.
--- @class test.harness
--- @field is_ci fun(name?: 'github'): boolean
--- @field on_suite_end fun(callback: fun()): fun()
--- @field read_nvim_log fun(logfile?: string, ci_rename?: boolean): string?
local M = {}

--- @type test.harness.State
local state = {
  suite_end_callbacks = {},
  current_define_suite = nil,
  current_execution = nil,
}

--- Return the current wall-clock time in seconds.
--- @return number
local function now_seconds()
  local sec, usec = assert(uv.gettimeofday())
  return sec + usec * 1e-6
end

--- Check whether the harness is running in CI, optionally for one provider.
--- @param name? 'github'
--- @return boolean
function M.is_ci(name)
  local any_provider = (name == nil)
  assert(any_provider or name == 'github')
  local github_actions = ((any_provider or name == 'github') and nil ~= os.getenv('GITHUB_ACTIONS'))
  return github_actions
end

--- Read the last `keep` lines from a file.
--- @param path string
--- @param keep integer
--- @return string[]?
local function read_tail_lines(path, keep)
  local file = io.open(path, 'r')
  if not file then
    return nil
  end

  local lines = {}
  for line in file:lines() do
    lines[#lines + 1] = line
    if #lines > keep then
      table.remove(lines, 1)
    end
  end
  file:close()
  return lines
end

-- TODO(lewis6991): move out of harness
--- Read and optionally rename the current Nvim log for failure output.
--- @param logfile? string
--- @param ci_rename? boolean
--- @return string?
function M.read_nvim_log(logfile, ci_rename)
  logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log'
  if not uv.fs_stat(logfile) then
    return
  end

  local ci = M.is_ci()
  local keep = ci and 100 or 10
  local lines = read_tail_lines(logfile, keep) or {}
  local separator = ('-'):rep(78)
  local parts = {
    separator,
    '\n',
    string.format('$NVIM_LOG_FILE: %s\n', logfile),
    #lines > 0 and string.format('(last %d lines)\n', keep) or '(empty)\n',
  }
  for _, line in ipairs(lines) do
    parts[#parts + 1] = line
    parts[#parts + 1] = '\n'
  end
  if ci and ci_rename then
    os.rename(logfile, logfile .. '.displayed')
  end
  parts[#parts + 1] = separator
  parts[#parts + 1] = '\n'
  return table.concat(parts)
end

--- Normalize a path relative to the current working directory.
--- @param path string
--- @return string
local function normalize_path(path)
  return vim.fs.normalize(vim.fs.abspath(path))
end

--- Render a path relative to the current working directory when possible.
--- @param path string
--- @return string
local function display_path(path)
  path = normalize_path(path)
  local relative = vim.fs.relpath('.', path)
  if relative then
    return relative
  end
  return path
end

--- Restore a table to a previously captured shallow snapshot.
--- @generic K, V
--- @param current table<K, V>
--- @param snapshot table<K, V>
--- @param unset? fun(key: K)
--- @param set? fun(key: K, value: V)
local function restore_snapshot(current, snapshot, unset, set)
  unset = unset or function(k)
    rawset(current, k, nil)
  end
  set = set or function(k, v)
    rawset(current, k, v)
  end
  for k in pairs(current) do
    if rawget(snapshot, k) == nil then
      unset(k)
    end
  end
  for k, v in pairs(snapshot) do
    set(k, v)
  end
end

--- Restore the process state to a captured baseline.
--- @param baseline test.harness.RuntimeBaseline
local function restore_runtime_baseline(baseline)
  if uv.cwd() ~= baseline.cwd then
    uv.chdir(baseline.cwd)
  end

  package.path = baseline.package_path
  package.cpath = baseline.package_cpath
  restore_snapshot(package.preload, baseline.package_preload)
  restore_snapshot(package.loaded, baseline.loaded)
  restore_snapshot(_G, baseline.globals)
  restore_snapshot(uv.os_environ(), baseline.env, uv.os_unsetenv, uv.os_setenv)
  _G.arg = vim._copy(baseline.arg)
  state.current_define_suite = nil
  state.current_execution = nil
end

--- Restore the baseline and run GC cleanup.
--- @param baseline test.harness.RuntimeBaseline
local function cleanup_runtime_baseline(baseline)
  restore_runtime_baseline(baseline)
  -- One full cycle may only run __gc/finalizers for dead userdata/cdata.
  -- Those finalizers can release the last references to more uv/mpack objects,
  -- which do not become collectible until the next cycle. Collect twice before
  -- switching files or ending the harness so leak checks see only live state.
  collectgarbage('collect')
  collectgarbage('collect')
end

local harness_source = debug.getinfo(1, 'S').source
local test_assert = require('test.assert')
local assert_source = debug.getinfo(test_assert.eq, 'S').source

--- @param info? debug.Info
--- @return test.harness.Trace
local function trace_from_info(info)
  return {
    short_src = info and vim.fs.normalize(info.short_src or '') or '',
    currentline = info and info.currentline or 0,
  }
end

--- Capture the source location of a caller frame.
--- Walk upward until we find a user Lua frame so Lua 5.1 tail-call elision
--- does not collapse `it()`/hook registrations into `(tail call) @ -1`.
--- @param level? integer
--- @return test.harness.Trace
local function caller_trace(level)
  local frame = level or 3
  local fallback

  while true do
    local info = debug.getinfo(frame, 'Sln')
    if not info then
      return trace_from_info(fallback)
    end

    fallback = fallback or info
    if
      info.what ~= 'C'
      and info.source ~= harness_source
      and info.short_src ~= '(tail call)'
      and info.currentline > 0
    then
      return trace_from_info(info)
    end

    frame = frame + 1
  end
end

--- Register a suite-end callback, deduplicated by callsite.
--- @param callback fun()
--- @return fun()
function M.on_suite_end(callback)
  assert(type(callback) == 'function', 'on_suite_end() expects a function')
  local trace = caller_trace(3)
  local caller_info = debug.getinfo(2, 'S')
  local key_source = caller_info and caller_info.source
  if type(key_source) == 'string' and vim.startswith(key_source, '@') then
    key_source = vim.fs.normalize(key_source:sub(2))
  else
    key_source = trace.short_src
  end
  local key = ('%s:%d'):format(key_source, trace.currentline)
  for _, registration in ipairs(state.suite_end_callbacks) do
    if registration.key == key then
      return registration.fn
    end
  end
  table.insert(state.suite_end_callbacks, {
    fn = callback,
    trace = trace,
    key = key,
  })
  return callback
end

--- Create a suite node for the definition tree.
--- @param name string?
--- @param parent? test.harness.Suite
--- @param trace? test.harness.Trace
--- @param is_file? boolean
--- @return test.harness.Suite
local function create_suite(name, parent, trace, is_file)
  return {
    kind = 'suite',
    name = name or '',
    parent = parent,
    trace = trace,
    is_file = is_file or false,
    hooks = {
      setup = {},
      teardown = {},
      before_each = {},
      after_each = {},
    },
    children = {},
    selected_count = 0,
  }
end

--- Return the suite currently receiving test definitions.
--- @return test.harness.Suite
local function current_suite()
  assert(state.current_define_suite, 'test definition is not active')
  return state.current_define_suite
end

--- Add a test node to the current suite.
--- @param name string
--- @param fn? fun()
--- @param pending_message? string
--- @return test.harness.Test
local function register_test(name, fn, pending_message)
  assert(type(name) == 'string' and name ~= '', 'test name must be a non-empty string')
  if fn ~= nil then
    assert(type(fn) == 'function', 'test body must be a function')
  end

  local suite = current_suite()
  local test = {
    kind = 'test',
    name = name,
    fn = fn,
    parent = suite,
    trace = caller_trace(3),
    pending_message = pending_message,
  }
  table.insert(suite.children, test)
  return test
end

--- Build a hook registrar exposed in the test chunk environment.
--- @param kind test.harness.HookKind
--- @return fun(fn: fun())
local function chunk_hook(kind)
  return function(fn)
    assert(type(fn) == 'function', ('%s expects a function'):format(kind))
    table.insert(current_suite().hooks[kind], {
      fn = fn,
      trace = caller_trace(3),
    })
  end
end

-- Chunk environment
local chunk_env = {
  _G = _G,
  assert = test_assert,
  setup = chunk_hook('setup'),
  teardown = chunk_hook('teardown'),
  before_each = chunk_hook('before_each'),
  after_each = chunk_hook('after_each'),
}

--- Define a nested suite in the chunk environment.
--- @param name string
--- @param fn fun()
--- @return test.harness.Suite
function chunk_env.describe(name, fn)
  assert(type(name) == 'string', 'describe() expects a string')
  assert(type(fn) == 'function', 'describe() expects a function body')

  local parent = current_suite()
  local suite = create_suite(name, parent, caller_trace(3), false)
  table.insert(parent.children, suite)

  local previous_define_suite = state.current_define_suite
  state.current_define_suite = suite
  local ok, err = xpcall(fn, debug.traceback)
  state.current_define_suite = previous_define_suite

  if not ok then
    error(err, 0)
  end

  return suite
end

--- Define a test in the chunk environment.
--- @param name string
--- @param fn? fun()
--- @return test.harness.Test
function chunk_env.it(name, fn)
  return register_test(name, fn, nil)
end

--- Mark the current test as pending or define a pending test.
--- When called while a test or hook is running, this aborts the current
--- execution and reports the current test as pending with `name` as the
--- pending message.
--- When called during file definition, this registers a new pending test in
--- the current suite. In that form, `block` may be a string used as the
--- pending message.
--- @param name? string
--- @param block? fun()|string
--- @return boolean
function chunk_env.pending(name, block)
  if state.current_execution then
    error({
      __harness_pending = true,
      message = name or 'pending',
    }, 0)
  end
  local pending_message = type(block) == 'string' and block or nil
  register_test(name or 'pending', nil, pending_message)
  return false
end

--- Register a finalizer to run after the current test body.
--- @param fn fun()
function chunk_env.finally(fn)
  assert(type(fn) == 'function', 'finally() expects a function')
  assert(
    state.current_execution and state.current_execution.scope == 'test',
    'finally() must be called while a test body is running'
  )
  table.insert(state.current_execution.finalizers, {
    fn = fn,
    trace = caller_trace(3),
  })
end

--- Convert an arbitrary error value into printable text.
--- @param err any
--- @return string
local function format_error_value(err)
  if type(err) == 'string' then
    return err
  end

  local ok, inspected = pcall(vim.inspect, err)
  if ok then
    return inspected
  end

  return tostring(err)
end

--- Parse one traceback line into a source location.
--- @param text? string
--- @return test.harness.Trace?
local function parse_trace_line(text)
  if type(text) ~= 'string' then
    return
  end

  local src, line = text:match('^(.+):(%d+):')
  if not src or not line then
    return
  end

  return {
    short_src = vim.fs.normalize(src),
    currentline = tonumber(line) or 0,
  }
end

--- @param source string
--- @param candidate string
--- @return boolean
local function source_matches(source, candidate)
  if source == candidate then
    return true
  end

  local tail = source:match('^%.%.%.(.+)$')
  return tail ~= nil and vim.endswith(candidate, tail)
end

--- @param left? test.harness.Trace
--- @param right? test.harness.Trace
--- @return boolean
local function same_trace_source(left, right)
  if left == nil or right == nil then
    return false
  end
  return source_matches(left.short_src, right.short_src)
    or source_matches(right.short_src, left.short_src)
end

--- Extract a source location from an error message or traceback.
--- @param message? string
--- @param traceback? string
--- @return test.harness.Trace?
local function parse_error_trace(message, traceback)
  if type(message) == 'string' then
    local first_line = message:match('^[^\n]+')
    local trace = parse_trace_line(first_line)
    if trace then
      return trace
    end

    local load_path = first_line
      and first_line:match("^error loading module .+ from file '([^']+)':$")
    if load_path == nil and first_line then
      load_path = first_line:match('^error loading module .+ from file "([^"]+)":$')
    end
    if load_path then
      local detail_line = message:match('\n([^\n]+)')
      trace = parse_trace_line(detail_line and detail_line:gsub('^%s+', ''))
      local load_trace = {
        short_src = vim.fs.normalize(load_path),
        currentline = 0,
      }
      if trace and same_trace_source(trace, load_trace) then
        return trace
      end
    end
  end

  if type(traceback) == 'string' then
    for line in traceback:gmatch('[^\n]+') do
      local trace = parse_trace_line(line:gsub('^%s+', ''))
      if trace then
        return trace
      end
    end
  end
end

--- Capture only user-visible Lua frames for test and hook failures.
--- Once execution returns to the harness, the rest of the stack is framework
--- noise and should not be printed in verbose failure output.
--- @return string?
local function build_error_traceback()
  local lines = {}

  for level = 3, math.huge do
    local info = debug.getinfo(level, 'Sln')
    if not info then
      break
    end

    if info.source == harness_source then
      break
    end

    if (info.what == 'Lua' or info.what == 'main') and info.source ~= assert_source then
      local trace = trace_from_info(info)
      if trace.currentline > 0 and trace.short_src ~= '' then
        local location = ('%s:%d'):format(trace.short_src, trace.currentline)
        if info.what == 'main' then
          lines[#lines + 1] = ('\t%s: in main chunk'):format(location)
        elseif info.name and info.name ~= '' then
          lines[#lines + 1] = ("\t%s: in function '%s'"):format(location, info.name)
        elseif (info.linedefined or 0) > 0 then
          lines[#lines + 1] = ('\t%s: in function <%s:%d>'):format(
            location,
            trace.short_src,
            info.linedefined
          )
        else
          lines[#lines + 1] = ('\t%s: in function ?'):format(location)
        end
      end
    end
  end

  if #lines == 0 then
    return
  end

  return 'stack traceback:\n' .. table.concat(lines, '\n')
end

--- Normalize thrown values into harness error payloads.
--- @param err any
--- @return test.harness.ErrorPayload
local function exception_handler(err)
  if type(err) == 'table' and err.__harness_pending then
    --- @cast err test.harness.ErrorPayload
    return err
  end

  local message = format_error_value(err)
  local raw_traceback = debug.traceback('', 2)
  return {
    message = message,
    trace = parse_error_trace(message, raw_traceback),
    traceback = build_error_traceback() or raw_traceback,
  }
end

--- Convert a handled error payload into a test result.
--- @param err test.harness.ErrorPayload
--- @param fallback_status? test.harness.ResultStatus
--- @return test.harness.Result
local function decode_error(err, fallback_status)
  return {
    status = err.__harness_pending and 'pending' or fallback_status or 'failure',
    message = err.message,
    traceback = err.traceback,
    trace = err.trace or parse_error_trace(err.message, err.traceback),
  }
end

--- Run a callable under harness error handling and finalizer cleanup.
--- @param scope test.harness.ExecutionScope
--- @param callable test.harness.RegisteredCallback
--- @param fallback_status? test.harness.ResultStatus
--- @return test.harness.Result, test.harness.Trace?
local function run_callable(scope, callable, fallback_status)
  local previous_execution = state.current_execution
  --- @type test.harness.Execution
  local execution = {
    scope = scope,
    finalizers = {},
  }
  state.current_execution = execution

  local ok, err = xpcall(callable.fn, exception_handler)
  local finalizer_err
  local finalizer_trace
  for i = #execution.finalizers, 1, -1 do
    local finalizer = execution.finalizers[i]
    local finalizer_ok, ferr = xpcall(finalizer.fn, exception_handler)
    if not finalizer_ok and not finalizer_err then
      finalizer_err = ferr
      finalizer_trace = finalizer.trace
    end
  end

  state.current_execution = previous_execution

  local result = ok and { status = 'success' } or decode_error(err, fallback_status)
  local report_trace = not ok and callable.trace or nil
  if not finalizer_err then
    return result, report_trace
  end

  local finalizer_result = decode_error(finalizer_err, 'error')
  if result.status == 'success' then
    finalizer_result.status = 'error'
    return finalizer_result, finalizer_trace
  end

  if result.status == 'pending' then
    return {
      status = 'error',
      message = ('finally: %s'):format(finalizer_result.message),
      traceback = finalizer_result.traceback,
      trace = finalizer_result.trace,
    },
      finalizer_trace
  end

  result.message = result.message .. '\n\nfinally: ' .. finalizer_result.message
  if not result.traceback then
    result.traceback = finalizer_result.traceback
  end
  if not result.trace then
    result.trace = finalizer_result.trace
  end
  return result, report_trace
end

--- Prefer the parsed trace only when it points at the same source file as the
--- owning test or callback. Otherwise, fall back to the definition site.
--- @param trace? test.harness.Trace
--- @param fallback? test.harness.Trace
--- @return test.harness.Trace?
local function summary_trace(trace, fallback)
  if same_trace_source(trace, fallback) then
    return trace
  end
  return fallback or trace
end

--- Build the suite and test name parts used for reporting.
--- @param element test.harness.Element?
--- @return string[]
local function full_name_parts(element)
  local parts = {}
  local node = element
  while node do
    if node.kind == 'test' and node.name ~= '' then
      table.insert(parts, 1, node.name)
    elseif node.kind == 'suite' and not node.is_file and node.name ~= '' then
      table.insert(parts, 1, node.name)
    end
    node = node.parent
  end
  return parts
end

--- Return the full hierarchical name for a suite or test.
--- @param element test.harness.Element
--- @return string
function M.get_full_name(element)
  return table.concat(full_name_parts(element), ' ')
end

--- Check whether a test matches the current selection options.
--- @param test test.harness.Test
--- @param opts test.harness.Options
--- @return boolean
local function test_selected(test, opts)
  test.full_name = M.get_full_name(test)

  if #opts.tags > 0 then
    local tagged = false
    for tag in test.full_name:gmatch('#([%w_%-]+)') do
      if vim.list_contains(opts.tags, tag) then
        tagged = true
        break
      end
    end

    if not tagged then
      return false
    end
  end

  if opts.filter and not test.full_name:match(opts.filter) then
    return false
  end

  for _, filter_out in ipairs(opts.filter_out) do
    if test.full_name:match(filter_out) then
      return false
    end
  end

  return true
end

--- Mark selected tests in a suite subtree and count them.
--- @param node test.harness.Suite
--- @param opts test.harness.Options
--- @return integer
local function mark_selected(node, opts)
  local selected_count = 0
  for _, child in ipairs(node.children) do
    if child.kind == 'suite' then
      selected_count = selected_count + mark_selected(child, opts)
    else
      --- @cast child test.harness.Test
      child.selected = test_selected(child, opts)
      if child.selected then
        selected_count = selected_count + 1
      end
    end
  end

  node.selected_count = selected_count
  return selected_count
end

--- Collect inherited `before_each` hooks from outermost to innermost.
--- @param suite test.harness.Suite
--- @return test.harness.RegisteredCallback[]
local function gather_before_each(suite)
  local hooks = {}
  if suite.parent then
    vim.list_extend(hooks, gather_before_each(suite.parent))
  end
  for _, hook in ipairs(suite.hooks.before_each) do
    hooks[#hooks + 1] = hook
  end
  return hooks
end

--- Collect inherited `after_each` hooks from innermost to outermost.
--- @param suite test.harness.Suite
--- @return test.harness.RegisteredCallback[]
local function gather_after_each(suite)
  local hooks = {}
  for _, hook in ipairs(suite.hooks.after_each) do
    hooks[#hooks + 1] = hook
  end
  if suite.parent then
    vim.list_extend(hooks, gather_after_each(suite.parent))
  end
  return hooks
end

--- Finalized result record passed to the reporter and stored in summaries.
--- @class test.harness.Record
--- @field name string
--- @field status test.harness.ResultStatus
--- @field trace? test.harness.Trace
--- @field duration number
--- @field message? string
--- @field traceback? string

--- Execution summary accumulated by the harness for one suite iteration.
--- @class test.harness.RunSummary
--- @field file_count integer
--- @field result_count integer
--- @field test_count integer
--- @field success_count integer
--- @field skipped_count integer
--- @field failure_count integer
--- @field error_count integer
--- @field pendings test.harness.Record[]
--- @field failures test.harness.Record[]
--- @field errors test.harness.Record[]

--- Per-file summary accumulated by the harness while one file runs.
--- @class test.harness.FileRunSummary
--- @field test_count integer

--- Build the reporter record for a completed result.
--- @param name string
--- @param result test.harness.Result
--- @param trace? test.harness.Trace
--- @param duration number
--- @return test.harness.Record
local function build_record(name, result, trace, duration)
  return {
    name = name,
    status = result.status,
    trace = trace,
    duration = duration,
    message = result.message,
    traceback = result.traceback,
  }
end

--- Record a completed test or synthetic result into the harness summary.
--- @param summary test.harness.RunSummary
--- @param file_summary? test.harness.FileRunSummary
--- @param record test.harness.Record
--- @param count_as_test? boolean
local function record_result(summary, file_summary, record, count_as_test)
  summary.result_count = summary.result_count + 1
  if count_as_test ~= false then
    summary.test_count = summary.test_count + 1
    if file_summary then
      file_summary.test_count = file_summary.test_count + 1
    end
  end

  if record.status == 'success' then
    summary.success_count = summary.success_count + 1
  elseif record.status == 'pending' then
    summary.skipped_count = summary.skipped_count + 1
    table.insert(summary.pendings, record)
  elseif record.status == 'failure' then
    summary.failure_count = summary.failure_count + 1
    table.insert(summary.failures, record)
  else -- error
    summary.error_count = summary.error_count + 1
    table.insert(summary.errors, record)
  end
end

--- Report a synthetic result as a test-shaped record.
--- @param reporter test.base_reporter
--- @param summary test.harness.RunSummary
--- @param file_summary? test.harness.FileRunSummary
--- @param parent test.harness.Suite
--- @param phase string
--- @param result test.harness.Result
--- @param trace? test.harness.Trace
--- @return test.harness.ResultStatus
local function run_synthetic_result(reporter, summary, file_summary, parent, phase, result, trace)
  local name = M.get_full_name(parent)
  if name == '' then
    name = parent.name ~= '' and parent.name or 'suite'
  end
  name = ('%s [%s]'):format(name, phase)
  local record_trace = trace or result.trace or parent.trace
  local record = build_record(name, result, record_trace, 0)
  reporter:test_start(record.name)
  record_result(summary, file_summary, record, false)
  reporter:test_end(record)
  return record.status
end

--- Run registered suite-end callbacks and report failures.
--- @param reporter test.base_reporter
--- @param summary test.harness.RunSummary
--- @return boolean
local function run_suite_end_callbacks(reporter, summary)
  local suite_end_callbacks = vim._copy(state.suite_end_callbacks)
  local callback_failed = false

  for index, callback in ipairs(suite_end_callbacks) do
    local result, report_trace = run_callable('suite_end', callback, 'error')
    if result.status ~= 'success' then
      callback_failed = true
      result.status = 'error'
      local name = ('[suite_end %d]'):format(index)
      local record_trace = summary_trace(result.trace, report_trace or callback.trace)
      local record = build_record(name, result, record_trace, 0)
      reporter:test_start(record.name)
      record_result(summary, nil, record, false)
      reporter:test_end(record)
    end
  end

  return callback_failed
end

--- Run a single test with its surrounding before and after hooks.
--- @param test test.harness.Test
--- @param reporter test.base_reporter
--- @param summary test.harness.RunSummary
--- @param file_summary test.harness.FileRunSummary
--- @return test.harness.ResultStatus
local function run_test(test, reporter, summary, file_summary)
  local name = test.full_name or M.get_full_name(test)

  local start_time = now_seconds()
  --- @type test.harness.Result
  local result
  local report_trace = test.trace
  if test.fn == nil then
    reporter:test_start(name)
    result = {
      status = 'pending',
      message = test.pending_message,
    }
  else
    result = { status = 'success' }

    for _, hook in ipairs(gather_before_each(test.parent)) do
      result, report_trace = run_callable('before_each', hook, 'failure')
      if result.status ~= 'success' then
        break
      end
    end

    reporter:test_start(name)

    if result.status == 'success' then
      result, report_trace = run_callable('test', { fn = test.fn, trace = test.trace }, 'failure')
    end

    for _, hook in ipairs(gather_after_each(test.parent)) do
      local hook_result, hook_trace = run_callable('after_each', hook, 'failure')
      if result.status == 'success' then
        result = hook_result
        report_trace = hook_trace
      elseif hook_result.status ~= 'success' then
        local hook_report_trace = hook_trace or hook.trace
        result.message = (result.message or '')
          .. (result.message and result.message ~= '' and '\n\n' or '')
          .. 'after_each: '
          .. hook_result.message
        if not result.traceback then
          result.traceback = hook_result.traceback
        end
        if not result.trace then
          result.trace = hook_result.trace
        end
        if result.status == 'pending' then
          result.status = 'error'
          report_trace = hook_report_trace
        elseif not report_trace then
          report_trace = hook_report_trace
        end
      end
    end
  end

  test.duration = now_seconds() - start_time
  local record = build_record(
    name,
    result,
    summary_trace(result.trace, report_trace or test.trace),
    test.duration
  )
  record_result(summary, file_summary, record)
  reporter:test_end(record)
  return record.status
end

--- Run a suite subtree until completion or a stop condition.
--- @param suite test.harness.Suite
--- @param reporter test.base_reporter
--- @param summary test.harness.RunSummary
--- @param file_summary test.harness.FileRunSummary
--- @param opts test.harness.Options
--- @return boolean
local function run_suite(suite, reporter, summary, file_summary, opts)
  if suite.selected_count == 0 then
    return false
  end

  local stop_requested = false
  local run_children = true

  -- Run setup() hooks
  for _, hook in ipairs(suite.hooks.setup) do
    local result = run_callable('setup', hook, 'error')
    if result.status ~= 'success' then
      run_synthetic_result(reporter, summary, file_summary, suite, 'setup', result, hook.trace)
      run_children = false
      if result.status ~= 'pending' and not opts.keep_going then
        stop_requested = true
      end
      break
    end
  end

  if run_children and not stop_requested then
    for _, child in ipairs(suite.children) do
      if child.kind == 'suite' then
        stop_requested = run_suite(child, reporter, summary, file_summary, opts) or stop_requested
      elseif child.selected then
        local status = run_test(child, reporter, summary, file_summary)
        if status ~= 'success' and status ~= 'pending' and not opts.keep_going then
          stop_requested = true
        end
      end

      if stop_requested then
        break
      end
    end
  end

  -- Run teardown() hooks
  for _, hook in ipairs(suite.hooks.teardown) do
    local result = run_callable('teardown', hook, 'error')
    if result.status ~= 'success' then
      run_synthetic_result(reporter, summary, file_summary, suite, 'teardown', result, hook.trace)
      if result.status ~= 'pending' and not opts.keep_going then
        stop_requested = true
      end
    end
  end

  return stop_requested
end

--- Collect test files from a file or directory path.
--- @param path string
--- @param files test.harness.FileEntry[]
--- @param seen_files table<string, boolean>
--- @return boolean?, string?
local function collect_test_files(path, files, seen_files)
  --- @param file string
  local function add_test_file(file)
    local abs_file = normalize_path(file)
    if seen_files[abs_file] then
      return
    end

    seen_files[abs_file] = true
    files[#files + 1] = {
      path = abs_file,
      display_name = display_path(abs_file),
    }
  end

  local abs = normalize_path(path)
  local stat = uv.fs_stat(abs)
  if not stat then
    return nil, ('test path not found: %s'):format(path)
  end

  if stat.type == 'file' then
    add_test_file(abs)
    return true
  end

  if stat.type ~= 'directory' then
    return nil, ('unsupported test path: %s'):format(path)
  end

  for _, file in
    ipairs(vim.fs.find(function(name)
      return name:match('_spec%.lua$') ~= nil
    end, {
      path = abs,
      type = 'file',
      limit = math.huge,
    }))
  do
    add_test_file(file)
  end

  return true
end

--- Parse harness CLI arguments into execution options.
--- @param argv string[]
--- @return test.harness.Options?, string?
local function parse_args(argv)
  --- @type test.harness.Options
  local opts = {
    keep_going = true,
    verbose = false,
    repeat_count = 1,
    summary_file = '-',
    tags = {},
    filter_out = {},
    lpaths = {},
    cpaths = {},
    paths = {},
  }

  --- @type table<string, boolean>
  local seen_tags = {}

  --- @param flag string
  --- @return nil, string
  local function missing_value(flag)
    return nil, 'missing value for ' .. flag
  end

  --- Parse and validate the `--repeat` argument.
  --- @param value? string
  --- @return integer?, string?
  local function parse_repeat_count(value)
    if type(value) ~= 'string' or value == '' then
      return missing_value('--repeat')
    end

    local count = tonumber(value)
    if count == nil or count < 1 or count ~= math.floor(count) then
      return nil, ('invalid value for --repeat: %s'):format(value)
    end
    --- @cast count integer

    return count
  end

  --- @type table<string, fun()>
  local switch_options = {
    ['-v'] = function()
      opts.verbose = true
    end,
    ['--verbose'] = function()
      opts.verbose = true
    end,
    ['--no-keep-going'] = function()
      opts.keep_going = false
    end,
  }

  --- @param flag string
  --- @param value string
  --- @return string?, string?
  local function require_nonempty(flag, value)
    if value == '' then
      return missing_value(flag)
    end
    return value
  end

  --- @param setter fun(value: any)
  --- @return fun(value: any): boolean?, string?
  local function set_value(setter)
    return function(value)
      setter(value)
      return true
    end
  end

  --- @param parse fun(value: string): any?, string?
  --- @param setter fun(value: any)
  --- @return fun(value: string): boolean?, string?
  local function set_parsed_value(parse, setter)
    return function(value)
      local parsed, err = parse(value)
      if parsed == nil then
        return nil, err
      end
      setter(parsed)
      return true
    end
  end

  --- @param flag string
  --- @param setter fun(value: string)
  --- @return fun(value: string): boolean?, string?
  local function set_nonempty_value(flag, setter)
    return set_parsed_value(function(value)
      return require_nonempty(flag, value)
    end, setter)
  end

  --- Validate that a filter option contains a valid Lua pattern.
  --- @param flag string
  --- @param value string
  --- @return string?, string?
  local function validate_pattern(flag, value)
    local ok, err = pcall(string.match, '', value)
    if not ok then
      local message = tostring(err)
      local detail = message:match('malformed pattern.*') or message
      return nil, ('invalid value for %s: %s'):format(flag, detail)
    end
    return value
  end

  --- @param flag string
  --- @param setter fun(value: string)
  --- @return fun(value: string): boolean?, string?
  local function set_pattern_value(flag, setter)
    return set_parsed_value(function(value)
      return validate_pattern(flag, value)
    end, setter)
  end

  --- @param values string[]
  --- @return fun(value: string): boolean?, string?
  local function append_value(values)
    return set_value(function(value)
      table.insert(values, value)
    end)
  end

  --- @type table<string, fun(value: string): boolean?, string?>
  local value_options = {
    ['--repeat'] = set_parsed_value(parse_repeat_count, function(count)
      opts.repeat_count = count
    end),
    ['--helper'] = set_nonempty_value('--helper', function(value)
      opts.helper = value
    end),
    ['--summary-file'] = set_nonempty_value('--summary-file', function(value)
      opts.summary_file = value
    end),
    ['--tags'] = function(value)
      for token in value:gmatch('[^,%s]+') do
        local tag = token:gsub('^#', '')
        if tag ~= '' and not seen_tags[tag] then
          seen_tags[tag] = true
          table.insert(opts.tags, tag)
        end
      end
      return true
    end,
    ['--filter'] = set_pattern_value('--filter', function(pattern)
      opts.filter = pattern
    end),
    ['--filter-out'] = set_pattern_value('--filter-out', function(pattern)
      table.insert(opts.filter_out, pattern)
    end),
    ['--lpath'] = append_value(opts.lpaths),
    ['--cpath'] = append_value(opts.cpaths),
  }

  local i = 1

  --- @param arg string
  --- @return string, string?
  local function split_option(arg)
    local eq = arg:find('=', 1, true)
    if not eq then
      return arg, nil
    end
    return arg:sub(1, eq - 1), arg:sub(eq + 1)
  end

  --- Consume the next argv item as the value for `flag`.
  --- @param flag string
  --- @return string?, string?
  local function take_value(flag)
    i = i + 1
    local value = argv[i]
    if type(value) ~= 'string' then
      return missing_value(flag)
    end
    return value
  end

  --- Parse one named option and apply it to `opts`.
  --- @param arg string
  --- @return boolean, string?
  local function apply_named_option(arg)
    local switch_handler = switch_options[arg]
    if switch_handler then
      switch_handler()
      return true
    end

    local flag, value = split_option(arg)
    local handler = value_options[flag]
    if handler then
      if value == nil then
        local err
        value, err = take_value(flag)
        if not value then
          return false, err
        end
      end
      local ok, handler_err = handler(value)
      if not ok then
        return false, handler_err
      end
      return true
    end

    return false
  end

  while i <= #argv do
    local arg = assert(argv[i])
    local handled, err = apply_named_option(arg)
    if handled then
    elseif err then
      return nil, err
    elseif vim.startswith(arg, '-') then
      return nil, 'unknown test harness option: ' .. arg
    else
      opts.paths[#opts.paths + 1] = arg
    end
    i = i + 1
  end

  if #opts.paths == 0 then
    return nil, 'no test paths provided'
  end

  return opts
end

--- Load a Lua chunk and bind it to a shallow copy of the given environment.
--- @param path string
--- @param env table<any, any>
--- @return function?, string?
local function load_chunk(path, env)
  local chunk, err = loadfile(path)
  if not chunk then
    return nil, err
  end

  return setfenv(chunk, setmetatable(vim._copy(env), { __index = _G }))
end

--- Load a helper file before the test baseline is captured.
--- Helper files are preload-only: they may require modules, set defaults,
--- and register suite-end callbacks, but they do not define tests or hooks.
--- @param path string
--- @return boolean?, string?
local function load_helper(path)
  local helper_path = normalize_path(path)
  local chunk, err = load_chunk(helper_path, {
    _G = _G,
    assert = test_assert,
  })
  if not chunk then
    return nil, err
  end
  local ok, load_err = xpcall(chunk, debug.traceback)
  if not ok then
    return nil, load_err
  end

  return true
end

--- Evaluate a test file into a per-file root suite.
--- @param file test.harness.FileEntry
--- @param root_suite test.harness.Suite
--- @return test.harness.Suite, test.harness.Result?
local function evaluate_test_file(file, root_suite)
  local file_suite = create_suite(file.display_name, root_suite, {
    short_src = file.display_name,
    currentline = 1,
  }, true)
  table.insert(root_suite.children, file_suite)

  state.current_define_suite = file_suite
  local chunk, load_err = load_chunk(file.path, chunk_env)
  if not chunk then
    state.current_define_suite = nil
    return file_suite,
      {
        status = 'error',
        message = load_err,
        trace = parse_error_trace(load_err, nil),
      }
  end

  local ok, runtime_err = xpcall(chunk, exception_handler)
  state.current_define_suite = nil
  if not ok then
    local load_error = decode_error(runtime_err, 'error')
    load_error.status = 'error'
    return file_suite, load_error
  end

  return file_suite
end

--- Run a single file in the current prepared runtime state.
--- @param file test.harness.FileEntry
--- @param reporter test.base_reporter
--- @param summary test.harness.RunSummary
--- @param opts test.harness.Options
--- @return boolean, boolean
local function run_test_file(file, reporter, summary, opts)
  local root_suite = create_suite('')
  local saved_suite_end_callbacks = vim._copy(state.suite_end_callbacks)

  local file_suite, load_error = evaluate_test_file(file, root_suite)

  local selected_count = mark_selected(root_suite, opts)

  if load_error then
    state.suite_end_callbacks = saved_suite_end_callbacks
  elseif selected_count == 0 then
    state.suite_end_callbacks = saved_suite_end_callbacks
    return false, false
  end

  --- @type test.reporter.FileElement
  local file_element = { name = file.display_name, duration = 0 }

  --- @type test.harness.FileRunSummary
  local file_summary = { test_count = 0 }

  reporter:file_start(file_element)

  local start = now_seconds()
  local stop_requested = false
  if load_error then
    local status = run_synthetic_result(
      reporter,
      summary,
      file_summary,
      file_suite,
      'load',
      load_error,
      load_error.trace
    )
    if status ~= 'success' and status ~= 'pending' and not opts.keep_going then
      stop_requested = true
    end
  else
    stop_requested = run_suite(root_suite, reporter, summary, file_summary, opts)
  end

  file_element.duration = now_seconds() - start
  summary.file_count = summary.file_count + 1

  reporter:file_end(file_element, file_summary.test_count)

  return true, stop_requested
end

--- Aggregate outcome from running one suite iteration.
--- @class test.harness.IterationResult
--- @field ran_any boolean
--- @field stop_requested boolean
--- @field summary test.harness.RunSummary

--- Run one full suite iteration across the selected files.
--- @param Reporter test.base_reporter
--- @param opts test.harness.Options
--- @param files test.harness.FileEntry[]
--- @param pre_helper_baseline test.harness.RuntimeBaseline
--- @param repeat_index integer
--- @return test.harness.IterationResult?, string?
local function run_iteration(Reporter, opts, files, pre_helper_baseline, repeat_index)
  local reporter = Reporter.new({
    paths = opts.paths,
    verbose = opts.verbose,
    summary_file = opts.summary_file,
  })
  --- @type test.harness.RunSummary
  local summary = {
    file_count = 0,
    result_count = 0,
    test_count = 0,
    success_count = 0,
    skipped_count = 0,
    failure_count = 0,
    error_count = 0,
    pendings = {},
    failures = {},
    errors = {},
  }
  restore_runtime_baseline(pre_helper_baseline)
  state.suite_end_callbacks = {}
  if opts.helper then
    local ok, err = load_helper(opts.helper)
    if not ok then
      return nil, err
    end
  end
  --- @type test.harness.RuntimeBaseline
  local file_baseline = {
    cwd = assert(uv.cwd()),
    package_path = package.path,
    package_cpath = package.cpath,
    package_preload = vim._copy(package.preload),
    globals = vim._copy(_G),
    loaded = vim._copy(package.loaded),
    env = vim._copy(uv.os_environ()),
    arg = vim._copy(_G.arg or {}),
  }
  reporter:suite_start(repeat_index, opts.repeat_count)

  local start_time = now_seconds()
  local ran_any = false
  local stop_requested = false

  for _, file in ipairs(files) do
    restore_runtime_baseline(file_baseline)
    local ran_file, stop = run_test_file(file, reporter, summary, opts)
    cleanup_runtime_baseline(file_baseline)
    ran_any = ran_any or ran_file
    if stop then
      stop_requested = true
      break
    end
  end

  local duration = now_seconds() - start_time

  run_suite_end_callbacks(reporter, summary)
  cleanup_runtime_baseline(file_baseline)

  state.suite_end_callbacks = {}

  local failure_output
  if summary.failure_count > 0 or summary.error_count > 0 then
    failure_output = M.read_nvim_log(nil, true)
  end

  reporter:suite_end(duration, summary, failure_output)

  return {
    ran_any = ran_any,
    stop_requested = stop_requested,
    summary = summary,
  }
end

--- Run the test harness CLI entrypoint.
--- @param argv string[]
--- @return integer
function M.main(argv)
  if os.getenv('BUSTED_ARGS') ~= nil then
    io.stderr:write('$BUSTED_ARGS is no longer supported; use $TEST_ARGS instead.\n')
    return 1
  end

  local opts, err = parse_args(argv)
  if not opts then
    io.stderr:write(err .. '\n')
    return 1
  end

  if #opts.lpaths > 0 then
    package.path = table.concat(opts.lpaths, ';') .. ';' .. package.path
  end

  if #opts.cpaths > 0 then
    package.cpath = table.concat(opts.cpaths, ';') .. ';' .. package.cpath
  end

  --- @type test.harness.RuntimeBaseline
  local pre_helper_baseline = {
    cwd = assert(uv.cwd()),
    package_path = package.path,
    package_cpath = package.cpath,
    package_preload = vim._copy(package.preload),
    globals = vim._copy(_G),
    loaded = vim._copy(package.loaded),
    env = vim._copy(uv.os_environ()),
    arg = vim._copy(_G.arg or {}),
  }
  local files = {} --- @type test.harness.FileEntry[]
  local seen_files = {} --- @type table<string, boolean>
  for _, path in ipairs(opts.paths) do
    local ok, collect_err = collect_test_files(path, files, seen_files)
    if not ok then
      io.stderr:write(collect_err .. '\n')
      return 1
    end
  end

  table.sort(files, function(a, b)
    return a.display_name < b.display_name
  end)

  if #files == 0 then
    io.stderr:write('No test files found.\n')
    return 1
  end

  local ReporterModule = require('reporter')
  local exit_code = 0
  local ran_any = false
  for repeat_index = 1, opts.repeat_count do
    local result, run_err =
      run_iteration(ReporterModule, opts, files, pre_helper_baseline, repeat_index)
    if not result then
      io.stderr:write(run_err .. '\n')
      return 1
    end

    ran_any = ran_any or result.ran_any

    if not result.ran_any and result.summary.result_count == 0 then
      io.stderr:write('No tests matched the current selection.\n')
      exit_code = 1
      break
    end

    if result.summary.failure_count > 0 or result.summary.error_count > 0 then
      exit_code = 1
    end

    if result.stop_requested then
      break
    end
  end

  if not ran_any then
    exit_code = 1
  end

  return exit_code
end

return M
