# test-langextract-bridge.R

test_that("field_resolver defaults use langextract engine and compatibility alias", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  defaults <- reticulate::py_to_r(core$`_normalize_orchestration_options`(list()))
  fr <- defaults$field_resolver

  expect_equal(fr$webpage_extraction_engine, "langextract")
  expect_true(isTRUE(fr$webpage_extraction_enabled))
  expect_true(isTRUE(fr$llm_webpage_extraction))
  expect_equal(fr$search_snippet_extraction_engine, "deterministic_then_legacy")
  expect_equal(as.integer(fr$llm_webpage_extraction_max_chars), 9000L)
  expect_equal(as.numeric(fr$search_snippet_extraction_timeout_s), 15)
  expect_equal(as.integer(fr$search_snippet_extraction_max_output_tokens), 160L)
  expect_equal(as.integer(fr$search_snippet_timeout_circuit_threshold), 2L)
  expect_equal(as.integer(fr$search_snippet_preferred_openwebpage_queue_size), 6L)
  expect_equal(as.integer(fr$langextract_extraction_passes), 2L)
  expect_equal(as.integer(fr$langextract_max_char_buffer), 2000L)

  legacy_off <- reticulate::py_to_r(core$`_normalize_orchestration_options`(list(
    field_resolver = list(
      llm_webpage_extraction = FALSE
    )
  )))

  fr_off <- legacy_off$field_resolver
  expect_false(isTRUE(fr_off$webpage_extraction_enabled))
  expect_false(isTRUE(fr_off$llm_webpage_extraction))

  triage_structured <- reticulate::py_to_r(core$`_normalize_orchestration_options`(list(
    field_resolver = list(
      search_snippet_extraction_engine = "triage_then_structured"
    )
  )))
  expect_equal(
    triage_structured$field_resolver$search_snippet_extraction_engine,
    "triage_then_structured"
  )
})

test_that("orchestration normalization applies latency profile webpage controls", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  latency <- reticulate::py_to_r(core$`_normalize_orchestration_options`(list(
    performance_profile = "latency"
  )))

  expect_equal(as.character(latency$performance_profile), "latency")
  expect_equal(as.integer(latency$retrieval_controller$max_empty_round_streak), 1L)
  expect_equal(as.integer(latency$retrieval_controller$webpage_policy$max_open_calls), 2L)
  expect_equal(as.integer(latency$field_resolver$llm_webpage_extraction_max_total_pages), 1L)
  expect_equal(as.numeric(latency$field_resolver$search_snippet_extraction_timeout_s), 6)
})

test_that("orchestration normalization applies quality snippet timeout default", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  quality <- reticulate::py_to_r(core$`_normalize_orchestration_options`(list(
    performance_profile = "quality"
  )))

  expect_equal(as.character(quality$performance_profile), "quality")
  expect_equal(as.numeric(quality$field_resolver$search_snippet_extraction_timeout_s), 20)
})

test_that("source URL normalization handles multiline and escaped-final-url tails", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  raw_multiline <- paste(
    "https://www.flickr.com/photos/194792615@N03/52180793916",
    "Final URL: https://www.flickr.com/photos/194792615@N03/52180793916",
    sep = "\n"
  )
  raw_escaped <- "https://www.flickr.com/photos/194792615@N03/52180793916\\nFinal"
  raw_encoded <- "https://www.flickr.com/photos/194792615@N03/52180793916%5CnFinal%20URL:%20https://www.flickr.com/photos/194792615@N03/52180793916"

  normalized_multiline <- as.character(core$`_normalize_url_match`(raw_multiline))
  normalized_escaped <- as.character(core$`_normalize_url_match`(raw_escaped))
  normalized_encoded <- as.character(core$`_normalize_url_match`(raw_encoded))

  expect_equal(normalized_multiline, "https://www.flickr.com/photos/194792615@N03/52180793916")
  expect_equal(normalized_escaped, "https://www.flickr.com/photos/194792615@N03/52180793916")
  expect_equal(normalized_encoded, "https://www.flickr.com/photos/194792615@N03/52180793916")
})

test_that("openwebpage text cleaner removes boilerplate and keeps evidence lines", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  raw_text <- paste(
    "URL: https://www.flickr.com/photos/194792615@N03/52180793916",
    "Final URL: https://www.flickr.com/photos/194792615@N03/52180793916",
    "Title: Ramona Moye Camaconi | Flickr",
    "Bytes read: 511523",
    "",
    "Relevant excerpts:",
    "",
    "[1]",
    "Explore",
    "[link: https://www.flickr.com/explore]",
    "Ramona Moye Camaconi es diputada electa por el partido MAS en Beni.",
    sep = "\n"
  )

  cleaned <- as.character(core$`_prepare_openwebpage_text_for_extraction`(
    raw_text,
    entity_tokens = list("Ramona", "Moye"),
    max_chars = 1200L
  ))

  expect_match(cleaned, "Ramona Moye Camaconi", fixed = TRUE)
  expect_no_match(cleaned, "URL:", fixed = TRUE)
  expect_no_match(cleaned, "\\[link:")
  expect_no_match(cleaned, "Explore", fixed = TRUE)
})

test_that("openwebpage hard-failure classifier catches blocked fetch outputs", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  blocked_text <- paste(
    "URL: https://ftierra.org/example",
    "Bytes read: 0",
    "Blocked fetch detected.",
    "Reason: http_403_bot_marker (status=403)",
    "Markers: enable javascript and cookies, title:just a moment",
    sep = "\n"
  )

  reason <- as.character(core$`_openwebpage_hard_failure_reason`(blocked_text))
  expect_equal(reason, "blocked_fetch")
})

test_that("openwebpage hard-failure classifier catches tool error JSON payloads", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  tool_error_json <- paste0(
    "{\"ok\":false,\"tool\":\"OpenWebpage\",\"error_type\":\"request_exception\",",
    "\"retryable\":true,\"url\":\"https://example.com\",\"error\":\"curl: (56)\"}"
  )

  reason <- as.character(core$`_openwebpage_hard_failure_reason`(tool_error_json))
  expect_equal(reason, "tool_error:request_exception")
})


test_that("langextract bridge normalizes extracted keys and source siblings", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  reticulate::py_run_string(paste0(
    "class _ASAExtraction:\n",
    "    def __init__(self, extraction_class, extraction_text, attributes=None):\n",
    "        self.extraction_class = extraction_class\n",
    "        self.extraction_text = extraction_text\n",
    "        self.attributes = attributes or {}\n",
    "_asa_ex1 = _ASAExtraction('full_name', 'Ada Lovelace')\n"
  ))

  out <- reticulate::py_to_r(bridge$normalize_langextract_output_to_allowed_keys(
    extractions = list(reticulate::py$`_asa_ex1`),
    allowed_keys = list("full_name", "full_name_source"),
    page_url = "https://example.com/profile"
  ))

  expect_equal(as.character(out$full_name), "Ada Lovelace")
  expect_equal(as.character(out$full_name_source), "https://example.com/profile")
})


test_that("langextract bridge normalizes gemini models/* route ids", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  reticulate::py_run_string(paste0(
    "class _ASAGeminiModel:\n",
    "    def __init__(self, model_name):\n",
    "        self.model_name = model_name\n",
    "_asa_gemini_model = _ASAGeminiModel('models/gemini-3-flash-preview')\n"
  ))

  route_pair <- bridge$`_resolve_route`(
    selector_model = reticulate::py$`_asa_gemini_model`,
    backend_hint = "gemini"
  )

  expect_null(route_pair[[2]])
  expect_equal(as.character(route_pair[[1]]$provider), "gemini")
  expect_equal(as.character(route_pair[[1]]$model_id), "gemini-3-flash-preview")
})


test_that("langextract bridge returns unsupported backend without importing provider runtime", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  reticulate::py_run_string(paste0(
    "class _ASADummyModel:\n",
    "    pass\n",
    "_asa_dummy_model = _ASADummyModel()\n"
  ))

  result <- reticulate::py_to_r(bridge$extract_schema_from_openwebpage_text(
    page_url = "https://example.com/profile",
    page_text = "name: Ada Lovelace",
    allowed_keys = list("full_name", "full_name_source"),
    schema_keys = list("full_name", "full_name_source"),
    selector_model = reticulate::py$`_asa_dummy_model`,
    backend_hint = "anthropic"
  ))

  expect_false(isTRUE(result$ok))
  expect_match(as.character(result$error), "unsupported_backend", fixed = TRUE)
})

test_that("openai-backed webpage extraction prefers structured output over langextract fallback", {
  core <- asa_test_import_langgraph_module(
    "asa_backend.graph.agent_graph_core",
    required_files = "asa_backend/graph/agent_graph_core.py"
  )

  original_bridge <- core$`_langextract_extract_schema_with_fallback`
  on.exit(
    reticulate::py_set_attr(core, "_langextract_extract_schema_with_fallback", original_bridge),
    add = TRUE
  )

  reticulate::py_run_string(paste0(
    "_asa_openai_structured_langextract_calls = 0\n",
    "_asa_openai_structured_with_calls = 0\n",
    "_asa_openai_structured_invoke_calls = 0\n",
    "_asa_openai_structured_legacy_calls = 0\n",
    "\n",
    "def _asa_openai_structured_langextract(**kwargs):\n",
    "    global _asa_openai_structured_langextract_calls\n",
    "    _asa_openai_structured_langextract_calls += 1\n",
    "    return {'ok': False, 'payload': {}, 'provider': 'gemini', 'error': 'should_not_be_called'}\n",
    "\n",
    "class _ASAOpenAIStructuredInvoker:\n",
    "    def invoke(self, messages, **kwargs):\n",
    "        global _asa_openai_structured_invoke_calls\n",
    "        _asa_openai_structured_invoke_calls += 1\n",
    "        return {'birth_place': 'London'}\n",
    "\n",
    "class _ASAOpenAIStructuredModel:\n",
    "    def with_structured_output(self, *args, **kwargs):\n",
    "        global _asa_openai_structured_with_calls\n",
    "        _asa_openai_structured_with_calls += 1\n",
    "        return _ASAOpenAIStructuredInvoker()\n",
    "    def invoke(self, messages, **kwargs):\n",
    "        global _asa_openai_structured_legacy_calls\n",
    "        _asa_openai_structured_legacy_calls += 1\n",
    "        class _Resp:\n",
    "            def __init__(self, content):\n",
    "                self.content = content\n",
    "        return _Resp('{}')\n",
    "\n",
    "_asa_openai_structured_langextract = _asa_openai_structured_langextract\n",
    "_asa_openai_structured_model = _ASAOpenAIStructuredModel()\n"
  ))
  reticulate::py_set_attr(
    core,
    "_langextract_extract_schema_with_fallback",
    reticulate::py$`_asa_openai_structured_langextract`
  )

  schema <- list(
    birth_place = "string|Unknown",
    birth_place_source = "string|null"
  )
  field_status <- list(
    birth_place = list(status = "unknown", value = "Unknown", source_url = NULL, attempts = 0L),
    birth_place_source = list(status = "pending", value = NULL, source_url = NULL, attempts = 0L)
  )
  tool_messages <- list(list(
    role = "tool",
    name = "OpenWebpage",
    content = paste(
      "URL: https://www.example.gov/profile",
      "Final URL: https://www.example.gov/profile",
      "Title: Example profile",
      "Relevant excerpts:",
      "Ada Lovelace was born in London.",
      sep = "\n"
    )
  ))

  openwebpage_result <- core$`_llm_extract_schema_payloads_from_openwebpages`(
    selector_model = reticulate::py$`_asa_openai_structured_model`,
    expected_schema = schema,
    field_status = field_status,
    tool_messages = tool_messages,
    entity_tokens = list("Ada", "Lovelace"),
    extracted_urls = list(),
    max_pages = 1L,
    max_chars = 1200L,
    extraction_engine = "langextract",
    langextract_backend_hint = "openai",
    debug = FALSE
  )

  openwebpage_r <- reticulate::py_to_r(openwebpage_result)
  payloads <- openwebpage_r[[1]]
  meta <- openwebpage_r[[3]]

  expect_equal(length(payloads), 1L)
  expect_equal(as.character(payloads[[1]]$payload$birth_place), "London")
  expect_match(
    as.character(payloads[[1]]$payload$birth_place_source),
    "example\\.gov/profile",
    perl = TRUE
  )
  expect_equal(as.integer(meta$structured_output_calls), 1L)
  expect_equal(as.integer(meta$langextract_calls), 0L)
  expect_equal(as.integer(reticulate::py_to_r(reticulate::py$`_asa_openai_structured_with_calls`)), 1L)
  expect_equal(as.integer(reticulate::py_to_r(reticulate::py$`_asa_openai_structured_invoke_calls`)), 1L)
  expect_equal(as.integer(reticulate::py_to_r(reticulate::py$`_asa_openai_structured_legacy_calls`)), 0L)
  expect_equal(as.integer(reticulate::py_to_r(reticulate::py$`_asa_openai_structured_langextract_calls`)), 0L)
})


test_that("fuzzy key normalization resolves prefix/substring matches", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  reticulate::py_run_string(paste0(
    "class _ASAExtraction:\n",
    "    def __init__(self, extraction_class, extraction_text, attributes=None):\n",
    "        self.extraction_class = extraction_class\n",
    "        self.extraction_text = extraction_text\n",
    "        self.attributes = attributes or {}\n",
    "_asa_fuzzy_1 = _ASAExtraction('education', 'Law degree')\n",
    "_asa_fuzzy_2 = _ASAExtraction('occupation', 'Attorney')\n",
    "_asa_fuzzy_3 = _ASAExtraction('birth_date', '1975-03-15')\n",
    "_asa_fuzzy_4 = _ASAExtraction('party', 'MAS')\n"
  ))

  out <- reticulate::py_to_r(bridge$normalize_langextract_output_to_allowed_keys(
    extractions = list(
      reticulate::py$`_asa_fuzzy_1`,
      reticulate::py$`_asa_fuzzy_2`,
      reticulate::py$`_asa_fuzzy_3`,
      reticulate::py$`_asa_fuzzy_4`
    ),
    allowed_keys = list(
      "birth_date", "birth_date_source",
      "education_level", "education_level_source",
      "prior_occupation", "prior_occupation_source",
      "party", "party_source"
    ),
    page_url = "https://example.com/profile"
  ))

  # "education" should fuzzy-match to "education_level"
  expect_equal(as.character(out$education_level), "Law degree")
  # "occupation" should fuzzy-match to "prior_occupation"
  expect_equal(as.character(out$prior_occupation), "Attorney")
  # Exact matches should still work
  expect_equal(as.character(out$birth_date), "1975-03-15")
  expect_equal(as.character(out$party), "MAS")
})


test_that("entity_hint parameter enriches extraction prompt description", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  # entity_hint parameter should be accepted without error
  reticulate::py_run_string(paste0(
    "class _ASADummyModel:\n",
    "    pass\n",
    "_asa_dummy_model2 = _ASADummyModel()\n"
  ))

  result <- reticulate::py_to_r(bridge$extract_schema_from_openwebpage_text(
    page_url = "https://example.com/profile",
    page_text = "name: Ada Lovelace",
    allowed_keys = list("full_name"),
    schema_keys = list("full_name"),
    selector_model = reticulate::py$`_asa_dummy_model2`,
    backend_hint = "anthropic",
    entity_hint = "Ada Lovelace"
  ))

  # The function should return (even if unsupported backend)
  expect_false(isTRUE(result$ok))
  expect_match(as.character(result$error), "unsupported_backend", fixed = TRUE)
})


test_that("provider fallback function exists and accepts entity_hint", {
  bridge <- asa_test_import_module(
    "asa_backend.extraction.schema_langextract_bridge",
    required_file = "asa_backend/extraction/schema_langextract_bridge.py",
    required_modules = character(0)
  )

  expect_true(is.function(bridge$extract_schema_with_provider_fallback))

  reticulate::py_run_string(paste0(
    "class _ASADummyModel:\n",
    "    pass\n",
    "_asa_dummy_model3 = _ASADummyModel()\n"
  ))

  result <- reticulate::py_to_r(bridge$extract_schema_with_provider_fallback(
    page_url = "https://example.com/profile",
    page_text = "name: Ada Lovelace",
    allowed_keys = list("full_name"),
    schema_keys = list("full_name"),
    selector_model = reticulate::py$`_asa_dummy_model3`,
    backend_hint = "anthropic",
    entity_hint = "Ada Lovelace"
  ))

  expect_false(isTRUE(result$ok))
})
