# Ensure tests use the standard asa conda environment when available.
options(asa.default_conda_env = "asa_env")

.asa_test_cache <- new.env(parent = emptyenv())
.asa_test_cache$python_path <- ""
.asa_test_cache$conda_env_bound <- ""
.asa_test_cache$module_availability <- new.env(parent = emptyenv())

.asa_test_bind_conda_once <- function(conda_env = "asa_env") {
  if (!requireNamespace("reticulate", quietly = TRUE)) {
    return(invisible(FALSE))
  }
  if (!is.character(conda_env) || length(conda_env) != 1L || !nzchar(conda_env)) {
    return(invisible(FALSE))
  }
  if (isTRUE(reticulate::py_available(initialize = FALSE))) {
    return(invisible(FALSE))
  }
  if (identical(.asa_test_cache$conda_env_bound, conda_env)) {
    return(invisible(TRUE))
  }
  try(
    suppressWarnings(reticulate::use_condaenv(conda_env, required = FALSE)),
    silent = TRUE
  )
  .asa_test_cache$conda_env_bound <- conda_env
  invisible(TRUE)
}

.asa_test_bind_conda_once("asa_env")

.asa_test_normalize_required_files <- function(required_files = character()) {
  files <- required_files %||% character(0)
  files <- as.character(files)
  files <- files[nzchar(files)]
  if (length(files) == 0L) {
    return(files)
  }

  files[files == "custom_ddg_production.py"] <- "asa_backend/graph/agent_graph_core.py"
  files[files == "asa_backend/agent_graph.py"] <- "asa_backend/graph/agent_graph_core.py"
  files[files == "asa_backend/graph/_legacy_agent_graph.py"] <- "asa_backend/graph/agent_graph_core.py"
  files[files == "asa_backend/search/_legacy_transport.py"] <- "asa_backend/search/ddg_transport.py"
  files[files == "asa_backend/search_transport.py"] <- "asa_backend/search/__init__.py"

  unique(files)
}

.asa_test_resolve_backend_module <- function(module_name, required_files = character()) {
  files <- .asa_test_normalize_required_files(required_files)

  if (identical(module_name, "custom_ddg_production")) {
    module_name <- "asa_backend.graph.agent_graph_core"
    files[files == "custom_ddg_production.py"] <- "asa_backend/graph/agent_graph_core.py"
    if (length(files) == 0L) {
      files <- "asa_backend/graph/agent_graph_core.py"
    }
  }
  if (identical(module_name, "asa_backend.graph._legacy_agent_graph")) {
    module_name <- "asa_backend.graph.agent_graph_core"
  }
  if (identical(module_name, "asa_backend.agent_graph")) {
    module_name <- "asa_backend.graph.agent_graph_core"
  }
  if (identical(module_name, "asa_backend.search_transport")) {
    module_name <- "asa_backend.search"
  }

  list(module_name = module_name, required_files = files)
}

# ---------------------------------------------------------------------------
# Shared Python / prompt helpers (avoid duplication across test files)
# ---------------------------------------------------------------------------

asa_test_python_path <- function(required_files = character()) {
  cached_path <- .asa_test_cache$python_path
  if (is.character(cached_path) && length(cached_path) == 1L &&
      nzchar(cached_path) && dir.exists(cached_path)) {
    required <- required_files
    if (is.null(required)) {
      required <- character(0)
    }
    required <- as.character(required)
    required <- required[nzchar(required)]
    if (length(required) == 0L || all(file.exists(file.path(cached_path, required)))) {
      return(cached_path)
    }
  }

  candidates <- c(
    # Common dev/test entrypoints:
    file.path(getwd(), "inst", "python"),
    file.path(getwd(), "asa", "inst", "python"),
    file.path(getwd(), "..", "inst", "python"),
    file.path(getwd(), "..", "..", "inst", "python"),
    # Installed package path:
    system.file("python", package = "asa")
  )

  candidates <- unique(normalizePath(candidates, winslash = "/", mustWork = FALSE))
  candidates <- candidates[nzchar(candidates)]

  if (is.null(required_files)) {
    required_files <- character(0)
  }
  required_files <- as.character(required_files)
  required_files <- required_files[nzchar(required_files)]

  for (path in candidates) {
    if (!dir.exists(path)) next
    ok <- TRUE
    for (rf in required_files) {
      if (!file.exists(file.path(path, rf))) {
        ok <- FALSE
        break
      }
    }
    if (ok) {
      .asa_test_cache$python_path <- path
      return(path)
    }
  }

  for (path in candidates) {
    if (dir.exists(path)) {
      .asa_test_cache$python_path <- path
      return(path)
    }
  }

  ""
}

asa_test_skip_if_no_python <- function(required_files = character(),
                                      initialize = TRUE,
                                      conda_env = NULL) {
  if (!requireNamespace("testthat", quietly = TRUE)) {
    stop("testthat is required for asa_test_skip_if_no_python()", call. = FALSE)
  }
  if (!requireNamespace("reticulate", quietly = TRUE)) {
    testthat::skip("reticulate not installed")
  }

  required_files <- .asa_test_normalize_required_files(required_files)
  python_path <- asa_test_python_path(required_files = required_files)
  if (!nzchar(python_path) || !dir.exists(python_path)) {
    testthat::skip("Python modules not found")
  }

  if (is.null(conda_env)) {
    conda_env <- tryCatch(asa:::.get_default_conda_env(), error = function(e) NULL)
  }
  if (!is.null(conda_env) && is.character(conda_env) && nzchar(conda_env)) {
    .asa_test_bind_conda_once(conda_env)
  }

  if (!reticulate::py_available(initialize = isTRUE(initialize))) {
    testthat::skip("Python not available")
  }

  python_path
}

asa_test_skip_if_missing_python_modules <- function(modules,
                                                   method = c("import", "py_module_available")) {
  if (!requireNamespace("testthat", quietly = TRUE)) {
    stop("testthat is required for asa_test_skip_if_missing_python_modules()", call. = FALSE)
  }
  if (!requireNamespace("reticulate", quietly = TRUE)) {
    testthat::skip("reticulate not installed")
  }

  if (is.null(modules)) {
    modules <- character(0)
  }
  modules <- as.character(modules)
  modules <- modules[nzchar(modules)]
  if (length(modules) == 0) {
    return(invisible(TRUE))
  }

  method <- match.arg(method)

  for (module in modules) {
    cache_key <- paste(method, module, sep = "::")
    if (exists(cache_key, envir = .asa_test_cache$module_availability, inherits = FALSE)) {
      ok <- isTRUE(get(cache_key, envir = .asa_test_cache$module_availability, inherits = FALSE))
    } else {
      ok <- if (method == "py_module_available") {
        isTRUE(reticulate::py_module_available(module))
      } else {
        tryCatch({
          reticulate::import(module, convert = FALSE)
          TRUE
        }, error = function(e) FALSE)
      }
      assign(cache_key, ok, envir = .asa_test_cache$module_availability)
    }

    if (!ok) {
      testthat::skip(paste0("Python module not available: ", module))
    }
  }

  invisible(TRUE)
}

asa_test_import_from_path_or_skip <- function(module_name, python_path) {
  if (!requireNamespace("testthat", quietly = TRUE)) {
    stop("testthat is required for asa_test_import_from_path_or_skip()", call. = FALSE)
  }
  if (!requireNamespace("reticulate", quietly = TRUE)) {
    testthat::skip("reticulate not installed")
  }

  resolved <- .asa_test_resolve_backend_module(module_name, required_files = character(0))
  tryCatch(
    reticulate::import_from_path(resolved$module_name, path = python_path),
    error = function(e) {
      testthat::skip(paste0("Failed to import ", resolved$module_name, ": ", conditionMessage(e)))
    }
  )
}

asa_test_import_module <- function(module_name,
                                   required_file = paste0(module_name, ".py"),
                                   required_modules = NULL,
                                   initialize = FALSE) {
  resolved <- .asa_test_resolve_backend_module(module_name, required_files = required_file)
  python_path <- asa_test_skip_if_no_python(
    required_files = resolved$required_files,
    initialize = initialize
  )
  if (!is.null(required_modules)) {
    asa_test_skip_if_missing_python_modules(required_modules)
  }
  reticulate::import_from_path(resolved$module_name, path = python_path)
}

ASA_TEST_DATE_EXTRACTOR_MODULES <- c("bs4", "requests")
ASA_TEST_LANGCHAIN_CORE_MODULES <- c("langchain_core", "pydantic", "requests")
ASA_TEST_LANGGRAPH_CORE_MODULES <- c("langchain_core", "langgraph", "pydantic", "requests")
ASA_TEST_LANGGRAPH_MODULES <- c(
  "langchain_core",
  "langgraph",
  "langgraph.prebuilt",
  "pydantic",
  "requests"
)

asa_test_date_extractor <- function() {
  asa_test_import_module(
    "shared.temporal_date_extractor",
    required_modules = ASA_TEST_DATE_EXTRACTOR_MODULES
  )
}

asa_test_require_langgraph_stack <- function(
    modules = ASA_TEST_LANGGRAPH_MODULES,
    method = "import"
) {
  asa_test_skip_if_missing_python_modules(modules, method = method)
  invisible(TRUE)
}

asa_test_import_research_graph <- function(
    required_modules = ASA_TEST_LANGGRAPH_CORE_MODULES
) {
  python_path <- asa_test_skip_if_no_python(
    required_files = "workflows/research_graph_workflow.py",
    initialize = FALSE
  )
  asa_test_require_langgraph_stack(required_modules)
  reticulate::import_from_path("workflows.research_graph_workflow", path = python_path)
}

# ---------------------------------------------------------------------------
# API key skip helpers (reduce 5-10 line boilerplate per test file)
# ---------------------------------------------------------------------------

asa_test_skip_api_tests <- function() {
  skip_on_cran()
  skip_if(
    tolower(Sys.getenv("ASA_CI_SKIP_API_TESTS")) %in% c("true", "1", "yes"),
    "ASA_CI_SKIP_API_TESTS is set"
  )
}

asa_test_require_gemini_key <- function() {
  api_key <- Sys.getenv("GOOGLE_API_KEY", unset = "")
  if (!nzchar(api_key)) {
    api_key <- Sys.getenv("GEMINI_API_KEY", unset = "")
  }
  skip_if_not(nzchar(api_key), "No Gemini API key available (GOOGLE_API_KEY or GEMINI_API_KEY)")
  api_key
}

asa_test_require_openai_key <- function() {
  api_key <- Sys.getenv("OPENAI_API_KEY", unset = "")
  skip_if_not(nzchar(api_key), "No OPENAI_API_KEY available")
  api_key
}

asa_test_require_openai_or_groq_key <- function() {
  if (nzchar(Sys.getenv("OPENAI_API_KEY"))) {
    return(list(backend = "openai", model = "gpt-4.1-mini",
                key = Sys.getenv("OPENAI_API_KEY")))
  }
  if (nzchar(Sys.getenv("GROQ_API_KEY"))) {
    return(list(backend = "groq", model = "llama-3.3-70b-versatile",
                key = Sys.getenv("GROQ_API_KEY")))
  }
  skip("No API key available (OPENAI_API_KEY or GROQ_API_KEY)")
}

# ---------------------------------------------------------------------------
# Selenium skip helper
# ---------------------------------------------------------------------------

asa_test_skip_if_no_selenium <- function() {
  skip_on_cran()
  run_selenium <- tolower(Sys.getenv("ASA_RUN_SELENIUM_TESTS")) %in% c("true", "1", "yes")
  skip_if(!run_selenium, "Set ASA_RUN_SELENIUM_TESTS=true to run Selenium tier tests")
}

# ---------------------------------------------------------------------------
# Tor integration test helpers
# ---------------------------------------------------------------------------

asa_test_skip_if_no_tor_tests_enabled <- function() {
  skip_on_cran()
  run_tor <- tolower(Sys.getenv("ASA_RUN_TOR_TESTS")) %in% c("true", "1", "yes")
  skip_if(!run_tor, "Set ASA_RUN_TOR_TESTS=true to run Tor integration tests")
}

asa_test_tor_proxy <- function() {
  proxy <- Sys.getenv("ASA_TOR_TEST_PROXY", unset = "socks5h://127.0.0.1:9050")
  if (!is.character(proxy) || length(proxy) != 1L || !nzchar(proxy)) {
    proxy <- "socks5h://127.0.0.1:9050"
  }
  proxy
}

asa_test_tor_control_port <- function() {
  port_raw <- Sys.getenv("ASA_TOR_TEST_CONTROL_PORT", unset = "9051")
  port <- suppressWarnings(as.integer(port_raw))
  if (is.na(port) || port <= 0L) {
    port <- 9051L
  }
  as.integer(port)
}

.asa_test_parse_proxy_port <- function(proxy, default = 9050L) {
  proxy_chr <- if (is.character(proxy) && length(proxy) == 1L) proxy else ""
  match <- regexec(":(\\d+)$", proxy_chr)
  tokens <- regmatches(proxy_chr, match)[[1]]
  if (length(tokens) >= 2L) {
    out <- suppressWarnings(as.integer(tokens[[2]]))
    if (!is.na(out) && out > 0L) {
      return(as.integer(out))
    }
  }
  as.integer(default)
}

.asa_test_is_port_open <- function(port, timeout = 2) {
  con <- NULL
  tryCatch({
    con <- socketConnection(host = "127.0.0.1", port = as.integer(port), timeout = timeout)
    TRUE
  }, error = function(e) {
    FALSE
  }, finally = {
    if (!is.null(con)) {
      try(close(con), silent = TRUE)
    }
  })
}

asa_test_require_tor_ready <- function() {
  asa_test_skip_if_no_tor_tests_enabled()

  proxy <- asa_test_tor_proxy()
  proxy_port <- .asa_test_parse_proxy_port(proxy, default = 9050L)
  control_port <- asa_test_tor_control_port()

  curl_path <- Sys.which("curl")
  if (!nzchar(curl_path)) {
    stop("Tor integration tests require `curl` on PATH.", call. = FALSE)
  }

  if (!isTRUE(asa::is_tor_running(port = proxy_port))) {
    stop(
      sprintf(
        "Tor SOCKS proxy is unreachable on 127.0.0.1:%d (proxy=%s).",
        proxy_port, proxy
      ),
      call. = FALSE
    )
  }

  if (!isTRUE(.asa_test_is_port_open(control_port))) {
    stop(
      sprintf("Tor control port is unreachable on 127.0.0.1:%d.", control_port),
      call. = FALSE
    )
  }

  cookie_path <- Sys.getenv("ASA_TOR_CONTROL_COOKIE", unset = "")
  if (nzchar(cookie_path) && !file.exists(cookie_path)) {
    stop(
      sprintf("Configured Tor control cookie does not exist: %s", cookie_path),
      call. = FALSE
    )
  }

  list(
    proxy = proxy,
    proxy_port = as.integer(proxy_port),
    control_port = as.integer(control_port),
    cookie_path = if (nzchar(cookie_path)) cookie_path else NULL
  )
}

asa_test_tor_probe <- function(proxy = asa_test_tor_proxy(), timeout = 30L) {
  curl_path <- Sys.which("curl")
  if (!nzchar(curl_path)) {
    stop("Tor probe requires `curl` on PATH.", call. = FALSE)
  }

  stdout_file <- tempfile(pattern = "asa_tor_probe_stdout_")
  stderr_file <- tempfile(pattern = "asa_tor_probe_stderr_")
  on.exit(unlink(c(stdout_file, stderr_file), force = TRUE), add = TRUE)

  status <- suppressWarnings(system2(
    command = curl_path,
    args = c(
      "-sS",
      "--proxy", proxy,
      "https://check.torproject.org/api/ip"
    ),
    stdout = stdout_file,
    stderr = stderr_file,
    timeout = as.integer(timeout)
  ))

  stdout_txt <- paste(readLines(stdout_file, warn = FALSE), collapse = "\n")
  stderr_txt <- paste(readLines(stderr_file, warn = FALSE), collapse = "\n")
  status_int <- suppressWarnings(as.integer(status %||% 0L))
  if (is.na(status_int)) status_int <- 0L

  if (status_int != 0L) {
    stop(
      sprintf(
        "Tor probe failed (status=%d, proxy=%s). stderr: %s",
        status_int, proxy, stderr_txt
      ),
      call. = FALSE
    )
  }

  parsed <- tryCatch(
    jsonlite::fromJSON(stdout_txt),
    error = function(e) {
      stop(
        sprintf("Tor probe returned non-JSON response: %s", stdout_txt),
        call. = FALSE
      )
    }
  )

  is_tor_raw <- if (!is.null(parsed$IsTor)) parsed$IsTor else parsed$is_tor
  is_tor <- if (is.logical(is_tor_raw)) {
    isTRUE(is_tor_raw)
  } else {
    tolower(as.character(is_tor_raw %||% "")) %in% c("true", "1", "yes")
  }
  ip_raw <- if (!is.null(parsed$IP)) parsed$IP else parsed$ip
  ip <- if (length(ip_raw) > 0L) as.character(ip_raw[[1]]) else ""

  list(
    is_tor = isTRUE(is_tor),
    ip = ip,
    payload = parsed,
    raw = stdout_txt
  )
}

# ---------------------------------------------------------------------------
# Agent-init-or-skip helper
# ---------------------------------------------------------------------------

asa_test_initialize_agent_or_skip <- function(backend = "auto", model = "auto",
                                              verbose = FALSE, ...) {
  if (backend == "auto" && model == "auto") {
    info <- asa_test_require_openai_or_groq_key()
    backend <- info$backend
    model <- info$model
  }
  tryCatch(
    initialize_agent(backend = backend, model = model, verbose = verbose, ...),
    error = function(e) skip(paste("Could not initialize agent:", e$message))
  )
}

# ---------------------------------------------------------------------------
# Stub LLM factory (replaces 10+ inline py_run_string blocks)
# ---------------------------------------------------------------------------

asa_test_stub_llm <- function(mode = c("simple", "tool_call", "recording",
                                        "json_response"),
                               response_content = "done",
                               tool_name = "Search",
                               tool_args = list(query = "x"),
                               tool_call_id = "call_1",
                               json_content = '{"status":"partial","items":[{"name":"Ada"}]}',
                               var_name = "stub_llm") {
  mode <- match.arg(mode)

  code <- switch(mode,
    simple = paste0(
      "from langchain_core.messages import AIMessage\n\n",
      "class _StubLLM:\n",
      "    def bind_tools(self, tools):\n",
      "        return self\n",
      "    def invoke(self, messages):\n",
      "        return AIMessage(content=", deparse(response_content), ")\n\n",
      var_name, " = _StubLLM()\n"
    ),
    tool_call = paste0(
      "from langchain_core.messages import AIMessage\n\n",
      "class _StubToolCallLLM:\n",
      "    def __init__(self):\n",
      "        self.n = 0\n",
      "    def bind_tools(self, tools):\n",
      "        return self\n",
      "    def invoke(self, messages):\n",
      "        self.n += 1\n",
      "        if self.n == 1:\n",
      "            return AIMessage(\n",
      "                content='calling tool',\n",
      "                tool_calls=[{'name':", deparse(tool_name),
                   ",'args':", jsonlite::toJSON(tool_args, auto_unbox = TRUE),
                   ",'id':", deparse(tool_call_id), "}],\n",
      "            )\n",
      "        return AIMessage(content=", deparse(response_content), ")\n\n",
      var_name, " = _StubToolCallLLM()\n"
    ),
    recording = paste0(
      "from langchain_core.messages import AIMessage\n\n",
      "class _RecordingLLM:\n",
      "    def __init__(self):\n",
      "        self.n = 0\n",
      "        self.system_prompts = []\n",
      "        self.seen_messages = []\n",
      "    def bind_tools(self, tools):\n",
      "        return self\n",
      "    def invoke(self, messages):\n",
      "        self.n += 1\n",
      "        try:\n",
      "            self.system_prompts.append(getattr(messages[0], 'content', None))\n",
      "        except Exception:\n",
      "            self.system_prompts.append(None)\n",
      "        try:\n",
      "            snap = []\n",
      "            for m in messages:\n",
      "                snap.append({'type': type(m).__name__, 'content': getattr(m, 'content', None)})\n",
      "            self.seen_messages.append(snap)\n",
      "        except Exception:\n",
      "            self.seen_messages.append([])\n",
      "        return AIMessage(content=", deparse(response_content), ")\n\n",
      var_name, " = _RecordingLLM()\n"
    ),
    json_response = paste0(
      "class _StubResponse:\n",
      "    def __init__(self, content):\n",
      "        self.content = content\n",
      "        self.tool_calls = None\n\n",
      "class _StubJsonLLM:\n",
      "    def bind_tools(self, tools):\n",
      "        return self\n",
      "    def invoke(self, messages):\n",
      "        return _StubResponse(", deparse(json_content), ")\n\n",
      var_name, " = _StubJsonLLM()\n"
    )
  )

  reticulate::py_run_string(code)
  reticulate::py[[var_name]]
}

# ---------------------------------------------------------------------------
# Stub summarizer factory (for memory folding tests)
# ---------------------------------------------------------------------------

asa_test_stub_summarizer <- function(
    summary_json = '{"version":1,"facts":["FOLDED_SUMMARY"],"decisions":[],"open_questions":[],"sources":[],"warnings":[]}',
    var_name = "stub_summarizer") {
  code <- paste0(
    "class _StubResponse:\n",
    "    def __init__(self, content):\n",
    "        self.content = content\n\n",
    "class _StubSummarizer:\n",
    "    def __init__(self):\n",
    "        self.calls = 0\n",
    "        self.last_prompt = None\n",
    "    def invoke(self, messages):\n",
    "        self.calls += 1\n",
    "        self.last_prompt = messages[0].content if messages else None\n",
    "        return _StubResponse(", deparse(summary_json), ")\n\n",
    var_name, " = _StubSummarizer()\n"
  )
  reticulate::py_run_string(code)
  reticulate::py[[var_name]]
}

# ---------------------------------------------------------------------------
# Multi-response stub LLM factory (for tests needing cycling responses)
# ---------------------------------------------------------------------------

asa_test_stub_multi_response_llm <- function(
    responses,
    supports_bind_tools = TRUE,
    var_name = "stub_llm") {

  # responses is a list of R lists, each with:
  #   content (string) and optionally tool_calls (list of lists with name/args/id)
  # The stub cycles through them in order, repeating the last one if exhausted.

  response_defs <- vapply(seq_along(responses), function(i) {
    r <- responses[[i]]
    tc <- r$tool_calls
    if (!is.null(tc) && length(tc) > 0) {
      tc_str <- paste0("[", paste(vapply(tc, function(t) {
        sprintf("{'name':%s,'args':%s,'id':%s}",
                deparse(t$name),
                jsonlite::toJSON(t$args, auto_unbox = TRUE),
                deparse(t$id))
      }, character(1)), collapse = ","), "]")
      sprintf("            _ResponseObj(content=%s, tool_calls=%s)",
              deparse(r$content), tc_str)
    } else {
      sprintf("            _ResponseObj(content=%s, tool_calls=None)",
              deparse(r$content))
    }
  }, character(1))

  responses_block <- paste(response_defs, collapse = ",\n")

  bind_tools_line <- if (supports_bind_tools) {
    "    def bind_tools(self, tools):\n        return self\n"
  } else {
    ""
  }

  code <- paste0(
    "from langchain_core.messages import AIMessage\n\n",
    "class _ResponseObj:\n",
    "    def __init__(self, content, tool_calls=None):\n",
    "        self.content = content\n",
    "        self.tool_calls = tool_calls\n",
    "        self.usage_metadata = {'total_tokens': 0}\n\n",
    "class _MultiResponseLLM:\n",
    "    def __init__(self):\n",
    "        self.n = 0\n",
    "        self._responses = [\n",
    responses_block, "\n",
    "        ]\n",
    bind_tools_line,
    "    def invoke(self, messages):\n",
    "        idx = min(self.n, len(self._responses) - 1)\n",
    "        self.n += 1\n",
    "        r = self._responses[idx]\n",
    "        if r.tool_calls:\n",
    "            return AIMessage(content=r.content, tool_calls=r.tool_calls)\n",
    "        return AIMessage(content=r.content)\n\n",
    var_name, " = _MultiResponseLLM()\n"
  )

  reticulate::py_run_string(code)
  reticulate::py[[var_name]]
}

# ---------------------------------------------------------------------------
# Test config factories (reduce SearchConfig boilerplate)
# ---------------------------------------------------------------------------

asa_test_fast_search_config <- function(ddg, ...) {
  defaults <- list(
    max_results = 3L,
    timeout = 0.2,
    max_retries = 1L,
    retry_delay = 0.0,
    backoff_multiplier = 1.0,
    captcha_backoff_base = 0.0,
    page_load_wait = 0.0,
    inter_search_delay = 0.0,
    humanize_timing = FALSE,
    jitter_factor = 0.0,
    allow_direct_fallback = FALSE
  )
  overrides <- list(...)
  args <- modifyList(defaults, overrides)
  do.call(ddg$SearchConfig, args)
}

# ---------------------------------------------------------------------------
# Mock data factories (reduce test object construction boilerplate)
# ---------------------------------------------------------------------------

asa_test_mock_enumerate_result <- function(
    data = data.frame(name = c("A", "B"), value = c(1, 2)),
    status = "complete",
    stop_reason = "target_reached",
    metrics = list(round_number = 3, queries_used = 15),
    provenance = NULL,
    plan = list(),
    query = "test query",
    ...) {
  asa_enumerate_result(
    data = data,
    status = status,
    stop_reason = stop_reason,
    metrics = metrics,
    provenance = provenance,
    plan = plan,
    query = query,
    ...
  )
}

asa_test_mock_audit_result <- function(
    data = data.frame(
      name = c("A", "B"),
      `_audit_flag` = c("ok", "warning"),
      check.names = FALSE
    ),
    audit_summary = "Test audit",
    issues = list(),
    recommendations = character(),
    completeness_score = 1.0,
    consistency_score = 1.0,
    backend_used = "claude_code",
    elapsed_time = 1.0,
    ...) {
  asa_audit_result(
    data = data,
    audit_summary = audit_summary,
    issues = issues,
    recommendations = recommendations,
    completeness_score = completeness_score,
    consistency_score = consistency_score,
    backend_used = backend_used,
    elapsed_time = elapsed_time,
    ...
  )
}

asa_test_mock_agent <- function(
    python_agent = NULL,
    backend = "openai",
    model = "gpt-4",
    config = list(use_memory_folding = TRUE),
    ...) {
  asa_agent(
    python_agent = python_agent,
    backend = backend,
    model = model,
    config = config,
    ...
  )
}

asa_test_recursion_limit_prompt <- function() {
  paste0(
    "You are doing a multi-step research task.\n",
    "1) You MUST call the Search tool first with the query: \"asa recursion limit test data\".\n",
    "   Do NOT output the final JSON until after you have tool output.\n",
    "2) After you get tool output, produce ONLY valid JSON with this exact schema:\n",
    "{\n",
    "  \"status\": \"complete\"|\"partial\",\n",
    "  \"items\": [\n",
    "    {\"name\": string, \"birth_year\": integer, \"field\": string, \"key_contribution\": string|null}\n",
    "  ],\n",
    "  \"missing\": [string],\n",
    "  \"notes\": string\n",
    "}\n",
    "Missing-data rules:\n",
    "- If you could not run Search or did not get tool output, set status=\"partial\".\n",
    "- Use null for unknown key_contribution.\n",
    "- List any unknown fields in missing.\n",
    "- Do NOT speculate.\n",
    "- If you include any items, only use facts supported by the Search output.\n"
  )
}

asa_test_recursion_limit_fixture_items <- function() {
  data.frame(
    name = c("Ada Lovelace", "Alan Turing", "Grace Hopper"),
    birth_year = c(1815L, 1912L, 1906L),
    field = c("mathematics", "computer science", "computer science"),
    stringsAsFactors = FALSE
  )
}

asa_test_has_search_tool_activity <- function(messages) {
  if (is.null(messages) || length(messages) == 0L) {
    return(FALSE)
  }
  any(vapply(messages, function(msg) {
    tool_calls <- tryCatch(msg$tool_calls, error = function(e) NULL)
    if (is.null(tool_calls) || length(tool_calls) == 0L) {
      return(FALSE)
    }
    any(vapply(tool_calls, function(call) {
      call_name <- tryCatch(as.character(call$name), error = function(e) character(0))
      length(call_name) > 0L && identical(call_name[[1]], "Search")
    }, logical(1)))
  }, logical(1)))
}
