test_that("build_node_trace_entry emits metadata fields", {
  utils <- asa_test_import_module(
    "shared.state_graph_utils",
    required_file = "shared/state_graph_utils.py",
    required_modules = c("pydantic"),
    initialize = TRUE
  )

  entry <- tryCatch(
    reticulate::py_to_r(utils$build_node_trace_entry(
      "tools",
      usage = reticulate::dict(input_tokens = 3L, output_tokens = 1L, total_tokens = 4L),
      elapsed_minutes = 0.25,
      status = "error",
      error_type = "timeout",
      tool_name = "search"
    )),
    error = function(e) NULL
  )

  expect_true(is.list(entry))
  expect_equal(as.character(entry$node), "tools")
  expect_equal(as.character(entry$status), "error")
  expect_equal(as.character(entry$error_type), "timeout")
  expect_equal(as.character(entry$tool_name), "search")
  expect_true(is.character(entry$started_at_utc))
  expect_true(is.character(entry$ended_at_utc))
  expect_true(grepl("Z$", as.character(entry$started_at_utc)))
  expect_true(grepl("Z$", as.character(entry$ended_at_utc)))
})

test_that("field-status diagnostics surface generic evidence-state samples and counters", {
  prod <- asa_test_import_langgraph_module(
    "custom_ddg_production",
    required_files = "custom_ddg_production.py",
    required_modules = ASA_TEST_LANGGRAPH_MODULES
  )

  collect_diag <- reticulate::py_get_attr(prod, "_collect_field_status_diagnostics")
  normalize_diag <- reticulate::py_get_attr(prod, "_normalize_diagnostics")

  field_status <- list(
    education_level = list(
      status = "unknown",
      value = "Unknown",
      source_url = NULL,
      evidence = "soft_anchor_penalty",
      evidence_reason = "soft_anchor_penalty",
      evidence_category = "soft_anchor_penalty",
      evidence_source_url = "https://example.com/profile",
      anchor_mode = "strict",
      anchor_strength = "strong",
      candidate_score = 0.41,
      descriptor = "string|Unknown"
    ),
    birth_place = list(
      status = "unknown",
      value = "Unknown",
      source_url = NULL,
      evidence = "explicit_contradiction_block",
      evidence_reason = "explicit_contradiction_block",
      evidence_category = "explicit_contradiction_block",
      evidence_source_url = "https://example.com/chile-profile",
      candidate_score = 0.67,
      descriptor = "string|Unknown"
    ),
    committee_role = list(
      status = "unknown",
      value = "Unknown",
      source_url = NULL,
      evidence = "high_conflict_demotion",
      evidence_reason = "high_conflict_demotion",
      evidence_category = "high_conflict_demotion",
      evidence_source_url = "https://example.com/alternate",
      candidate_score = 0.58,
      descriptor = "string|Unknown"
    ),
    prior_occupation = list(
      status = "found",
      value = "Teacher",
      source_url = "https://example.com/profile",
      evidence = "recovery_source_backed",
      evidence_reason = "recovery_promoted_anchor_soft_penalty",
      evidence_category = "soft_anchor_penalty",
      candidate_score = 0.82,
      descriptor = "string|Unknown"
    )
  )

  collected <- reticulate::py_to_r(collect_diag(field_status = field_status))
  expect_true(is.list(collected$anchor_mismatch_samples))
  expect_true(length(collected$anchor_mismatch_samples) >= 1)
  sample <- collected$evidence_state_samples[[1]]
  expect_true(as.character(sample$field) %in% c("education_level", "birth_place", "committee_role"))
  expect_true(as.character(collected$anchor_mismatch_samples[[1]]$field) %in% c("education_level", "prior_occupation"))
  expect_equal(as.character(collected$anchor_mismatch_samples[[1]]$source_url), "https://example.com/profile")
  expect_equal(as.integer(collected$evidence_state_reason_counts$soft_anchor_penalty), 1L)
  expect_equal(as.integer(collected$evidence_state_reason_counts$explicit_contradiction_block), 1L)
  expect_equal(as.integer(collected$evidence_state_reason_counts$high_conflict_demotion), 1L)
  expect_equal(as.integer(collected$weak_anchor_promotions_survived_finalization_count), 0L)

  normalized <- reticulate::py_to_r(normalize_diag(list(
    finalization_invariant_failures_by_reason = list(diagnostics_unknown_fields_mismatch = 2L),
    finalization_blocking_fields = list("education_level"),
    diagnostics_recovery_checkpoint_events = 1L,
    diagnostics_recovery_retry_events = 1L,
    weak_anchor_candidates_admitted_count = 3L,
    contradiction_blocks_count = 1L,
    strong_conflict_demotions_count = 2L,
    weak_anchor_promotions_survived_finalization_count = 1L,
    finalization_recovery_checkpoints = list(list(
      context = "agent",
      reason = "diagnostics_unknown_count_mismatch",
      reasons = list("diagnostics_unknown_count_mismatch"),
      priority = "anchor_mismatch_recovery",
      action = "replan_and_retry",
      blocking_fields_sample = list("education_level"),
      ts_utc = "2026-02-24T03:43:11Z"
    )),
    empty_tool_results_details = list(list(
      tool_name = "search",
      query_digest = "abcd1234",
      error_type = "timeout",
      round_index = 2L
    )),
    anchor_mismatch_samples = collected$anchor_mismatch_samples,
    evidence_state_samples = collected$evidence_state_samples
  )))

  expect_equal(
    as.integer(normalized$finalization_invariant_failures_by_reason$diagnostics_unknown_fields_mismatch),
    2L
  )
  expect_true("education_level" %in% as.character(normalized$finalization_blocking_fields))
  expect_equal(as.integer(normalized$diagnostics_recovery_checkpoint_events), 1L)
  expect_equal(as.integer(normalized$diagnostics_recovery_retry_events), 1L)
  expect_equal(as.integer(normalized$weak_anchor_candidates_admitted_count), 3L)
  expect_equal(as.integer(normalized$contradiction_blocks_count), 1L)
  expect_equal(as.integer(normalized$strong_conflict_demotions_count), 2L)
  expect_equal(as.integer(normalized$weak_anchor_promotions_survived_finalization_count), 1L)
  expect_true(is.list(normalized$finalization_recovery_checkpoints))
  expect_equal(as.character(normalized$finalization_recovery_checkpoints[[1]]$priority), "anchor_mismatch_recovery")
  expect_true(is.list(normalized$empty_tool_results_details))
  expect_equal(as.character(normalized$empty_tool_results_details[[1]]$tool_name), "search")
  expect_true(length(normalized$anchor_mismatch_samples) >= 1)
  expect_true(length(normalized$evidence_state_samples) >= 1)
})

test_that("schema outcome gate reconciles stale diagnostics snapshots", {
  prod <- asa_test_import_langgraph_module(
    "custom_ddg_production",
    required_files = "custom_ddg_production.py",
    required_modules = ASA_TEST_LANGGRAPH_MODULES
  )

  gate_report <- reticulate::py_get_attr(prod, "_schema_outcome_gate_report")
  state <- list(
    expected_schema = list(education_level = "string|Unknown"),
    field_status = list(
      education_level = list(
        status = "unknown",
        value = "Unknown",
        evidence = "recovery_blocked_anchor_mismatch",
        attempts = 1L
      )
    ),
    budget_state = list(
      tool_calls_used = 0L,
      tool_calls_limit = 6L,
      budget_exhausted = FALSE
    ),
    diagnostics = list(
      unknown_fields_count_current = 0L,
      unknown_fields_current = list(),
      recovery_reason_counts = list(recovery_blocked_anchor_mismatch = 1L)
    ),
    finalization_policy = list(
      diagnostics_auto_recovery_enabled = TRUE
    )
  )

  report <- reticulate::py_to_r(gate_report(
    state = state,
    expected_schema = state$expected_schema,
    field_status = state$field_status,
    budget_state = state$budget_state,
    diagnostics = state$diagnostics
  ))

  reasons <- as.character(if (is.null(report$finalization_invariant_reasons)) character(0) else report$finalization_invariant_reasons)
  expect_false(isTRUE(report$finalization_invariant_failed))
  expect_false(isTRUE(report$auto_recovery_checkpoint_needed))
  expect_equal(as.character(report$auto_recovery_action), "none")
  expect_equal(as.character(report$auto_recovery_priority), "invariant_reconciliation")
  expect_false("diagnostics_unknown_count_mismatch" %in% reasons)
})

test_that("schema outcome gate emits auto-recovery checkpoint hints for justification mismatch", {
  prod <- asa_test_import_langgraph_module(
    "custom_ddg_production",
    required_files = "custom_ddg_production.py",
    required_modules = ASA_TEST_LANGGRAPH_MODULES
  )

  gate_report <- reticulate::py_get_attr(prod, "_schema_outcome_gate_report")
  state <- list(
    expected_schema = list(
      prior_occupation = "string|Unknown",
      birth_place = "string|Unknown",
      justification = "string"
    ),
    field_status = list(
      prior_occupation = list(
        status = "found",
        value = "Teacher",
        source_url = "https://example.org/profile",
        evidence_score = 0.95
      ),
      birth_place = list(
        status = "found",
        value = "Beni",
        source_url = "https://example.org/profile",
        evidence_score = 0.95
      ),
      justification = list(
        status = "found",
        value = "Source-backed searches resolved 2 core fields while 0 core fields remain unknown.",
        source_url = NULL
      )
    ),
    diagnostics = list(
      unknown_fields_count_current = 0L,
      unknown_fields_current = list()
    ),
    budget_state = list(budget_exhausted = FALSE),
    finalization_policy = list(
      diagnostics_auto_recovery_enabled = TRUE
    ),
    messages = list(list(
      role = "assistant",
      content = paste0(
        "{\"prior_occupation\":\"Teacher\",",
        "\"birth_place\":\"Beni\",",
        "\"justification\":\"Source-backed searches resolved 1 core fields while 1 core fields remain unknown.\"}"
      )
    )),
    use_plan_mode = FALSE,
    json_repair = list()
  )

  report <- reticulate::py_to_r(gate_report(
    state = state,
    expected_schema = state$expected_schema,
    field_status = state$field_status,
    budget_state = state$budget_state,
    diagnostics = state$diagnostics
  ))

  reasons <- as.character(if (is.null(report$finalization_invariant_reasons)) character(0) else report$finalization_invariant_reasons)
  expect_true(isTRUE(report$finalization_invariant_failed))
  expect_true("terminal_justification_count_mismatch" %in% reasons)
  expect_true(isTRUE(report$auto_recovery_checkpoint_needed))
  expect_equal(as.character(report$auto_recovery_action), "replan_and_retry")
  expect_equal(as.character(report$auto_recovery_priority), "justification_reconciliation")
})

test_that("justification guard rejects mismatched included fields", {
  prod <- asa_test_import_langgraph_module(
    "custom_ddg_production",
    required_files = "custom_ddg_production.py",
    required_modules = ASA_TEST_LANGGRAPH_MODULES
  )

  check_justification <- reticulate::py_get_attr(prod, "_justification_counts_match_field_status")
  field_status <- list(
    birth_year = list(status = "found", value = 1980L),
    education_level = list(status = "unknown", value = "Unknown")
  )
  justification <- paste(
    "Source-backed searches resolved 1 core fields",
    "(including education_level), while 1 core fields remain unknown."
  )

  ok <- reticulate::py_to_r(check_justification(
    text = justification,
    normalized_field_status = field_status
  ))

  expect_false(isTRUE(ok))
})
