use std::collections::HashMap;

use crate::error::{Result, XfaError};
use crate::javascript_policy::{self, JavaScriptEntryPoint};
use crate::js_runtime::{
    activity_allowed_for_sandbox_with_gate, presave_during_flatten_enabled, NullRuntime,
    RuntimeMetadata, SandboxError, XfaJsRuntime,
};
use formcalc_interpreter::{
    interpreter::Interpreter, lexer::tokenize, parser, som_bridge::SomResolver,
    value::Value as FormCalcValue,
};
use xfa_dom_resolver::som::{parse_som, SomExpression, SomIndex, SomRoot, SomSelector};
use xfa_layout_engine::form::{
    EventScript, FormNodeId, FormNodeType, FormTree, GroupKind, Presence, ScriptLanguage,
};

// XFA Spec 3.3 §9.3 — Dynamic Forms Re-Layout: after script execution the
// layout processor must re-run layout.  The spec does not prescribe a fixed
// pass limit; Adobe typically converges in 2-3 passes.  Our limit of 3 is a
// pragmatic cap that matches observed Adobe behavior.
const MAX_SCRIPT_PASSES: usize = 3;

/// Controls only the pre-flight handling of parsed JavaScript-bearing XFA
/// event hooks. JavaScript execution remains denied by `javascript_policy`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JsExecutionMode {
    /// Abort before script execution when any JavaScript or unsupported script
    /// language is present. This preserves the original policy-gate behavior.
    Strict,
    /// Skip JavaScript and unsupported-language scripts, then continue running
    /// FormCalc and the layout pipeline. Skipped scripts are reported in the
    /// returned outcome.
    #[default]
    BestEffortStatic,
    /// **M3-B Phase B opt-in.** Route JavaScript scripts through the sandboxed
    /// runtime adapter (`crate::js_runtime`). Requires the Cargo feature
    /// `xfa-js-sandboxed` to be compiled in for any script to actually
    /// execute; without the feature the runtime returns
    /// [`SandboxError::NotCompiledIn`] and the dispatch path falls back to
    /// the same skip behaviour as [`Self::BestEffortStatic`] while incrementing
    /// `js_runtime_errors` so callers can observe the dead-code state.
    /// Phase B registers no host bindings; see
    /// `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md` for the Phase C
    /// roadmap.
    SandboxedRuntime,
}
/// Fidelity level of the flattened output relative to the source XFA data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputQuality {
    /// All data was bound and rendered without skipping any scripts or content.
    #[default]
    Exact,
    /// Some scripts were skipped (e.g. JavaScript with `BestEffortStatic` mode);
    /// output may differ from a full Adobe Reader render.
    BestEffort,
    /// All JavaScript scripts on the document executed inside the sandbox
    /// without runtime / timeout / OOM errors (requires `xfa-js-sandboxed` feature).
    Sandboxed,
}

impl OutputQuality {
    /// Return a short lowercase string label suitable for logging and metrics.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Exact => "exact",
            Self::BestEffort => "best_effort",
            Self::Sandboxed => "sandboxed",
        }
    }
}
/// Aggregate outcome of the dynamic script processing pass.
///
/// Returned by [`flatten_xfa_to_pdf_with_metadata`](crate::flatten_xfa_to_pdf_with_metadata)
/// and embedded in [`FlattenMetadata`](crate::FlattenMetadata).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DynamicScriptOutcome {
    /// Number of form field values that were mutated by scripts.
    pub changes: usize,
    /// True when the document contains at least one JavaScript event hook.
    pub js_present: bool,
    /// Number of JavaScript scripts that were skipped (not executed).
    pub js_skipped: usize,
    /// Number of scripts in unsupported languages (not FormCalc, not JavaScript) skipped.
    pub other_skipped: usize,
    /// Number of FormCalc scripts that ran successfully.
    pub formcalc_run: usize,
    /// Number of FormCalc scripts that produced an error.
    pub formcalc_errors: usize,
    /// Overall output quality after script processing.
    pub output_quality: OutputQuality,
    /// **M3-B Phase B.** Scripts that ran to completion in the sandboxed
    /// runtime. Always 0 when mode != [`JsExecutionMode::SandboxedRuntime`]
    /// or when the `xfa-js-sandboxed` feature is not compiled in.
    pub js_executed: usize,
    /// **M3-B Phase B.** Sandbox errors that did not fall under timeout / OOM
    /// (parse error, throw, missing host binding in Phase B, FFI panic). The
    /// dispatch path treats these as a script skip; the parent flatten never
    /// aborts because of them (S-17 fail-open).
    pub js_runtime_errors: usize,
    /// **M3-B Phase B.** Per-script time-budget exhaustions.
    pub js_timeouts: usize,
    /// **M3-B Phase B.** Per-document memory-budget exhaustions.
    pub js_oom: usize,
    /// **M3-B Phase C.** Host-binding invocations.
    pub js_host_calls: usize,
    /// **M3-B Phase C.** Successful host-side `field.rawValue` writes.
    pub js_mutations: usize,
    /// **M3-B Phase D.** Successful host-side instanceManager writes.
    pub js_instance_writes: usize,
    /// **M3-B Phase D-β.** Successful host-side listbox clearItems / addItem writes.
    pub js_list_writes: usize,
    /// **M3-B Phase C.** Binding-level failures.
    pub js_binding_errors: usize,
    /// **M3-B Phase C.** SOM resolution misses / failures.
    pub js_resolve_failures: usize,
    /// **M3-B Phase D-γ.** Successful DataDom reads (children / value / child-by-name).
    pub js_data_reads: usize,
    /// **M3-B Phase E (XFA-JS-HOST-STUBS).** Scripts touched a host capability
    /// that requires real viewer / user interaction (UI dialogs, signature,
    /// submit, openList, beep, ...). The stub returned a deterministic safe
    /// default so the script kept running; this counter records how often
    /// such a touch happened so callers can distinguish "would-have-been
    /// interactive" from genuine runtime errors.
    pub js_unsupported_host_calls: usize,
    /// **M3-B Phase D-θ.2.** Strict probe calls skipped because
    /// `parentIds.length == 1 && chain.length == 1` (no same-name
    /// sibling ambiguity possible). Each skipped call saves one
    /// `resolveWithFullChainStrict` host round-trip.
    pub js_probe_skips: usize,
    /// **D3 (trace-only).** `<variables>` `<script>` objects collected.
    pub variables_scripts_collected: usize,
    /// **D3 (trace-only).** `<variables>` `<text>` data items collected.
    pub variables_data_items_collected: usize,
    /// **D3 (trace-only).** Script objects / data items whose registration
    /// bound a namespace (JS-side `setVariables*` returned success).
    pub script_objects_registered: usize,
    /// **D3 (trace-only).** Script objects / data items that did NOT register
    /// (Rust skip or JS-side eval failure). Observability only.
    pub script_objects_register_failed: usize,
    /// **D3 (trace-only).** Script objects collected under a nested subform
    /// scope (registered to `subformVariables` only — not bare-ident visible).
    pub script_objects_subform_scoped: usize,
    /// **D4.** Total SOM lookups at the host resolve boundary.
    pub som_lookups_total: usize,
    /// **D4.** SOM lookups that resolved.
    pub som_lookup_successes: usize,
    /// **D4.** SOM lookups that returned NoMatch.
    pub som_lookup_failures: usize,
    /// **D4.** Subform-script names withheld for ambiguity (fail-closed).
    pub som_lookup_ambiguous: usize,
    /// **D4.** Subform-scoped script objects exposed to bare-identifier lookup.
    pub som_subform_scripts_exposed: usize,
    /// **D4 (trace-only).** `occur`-path SOM references (classified, not resolved).
    pub som_occur_path_refs: usize,
    /// **D5.** `node.occur` handle accesses (successes + failures).
    pub occur_lookups_total: usize,
    /// **D5.** `node.occur` accesses where the node handle was live.
    pub occur_lookup_successes: usize,
    /// **D5.** `node.occur` accesses where the node handle was not live.
    pub occur_lookup_failures: usize,
    /// **D5.** Reads of an occur property (`min`/`max`/`initial`).
    pub occur_property_reads: usize,
    /// **D5.** Writes to an occur property (captured, not applied).
    pub occur_property_writes: usize,
    /// **D5.** Writes specifically to `occur.min`.
    pub occur_min_writes: usize,
    /// **D5.** Writes specifically to `occur.max`.
    pub occur_max_writes: usize,
    /// **D5.** Occur mutations captured as intent (no layout effect).
    pub occur_mutations_captured: usize,
    /// **D5/D6.** Occur mutations APPLIED to layout (D5: 0; D6: >0 under `XFA_OCCUR_APPLY`).
    pub occur_mutations_applied: usize,
    /// **D6.** Captured occur mutations not applied (gate off / rollback / unsupported).
    pub occur_mutations_skipped: usize,
    /// **D6.** Captured occur mutations skipped because the target is not a
    /// repeatable container (fail-closed).
    pub occur_application_ambiguous: usize,
    /// **D6.** Distinct nodes whose occur was applied.
    pub occur_application_targets: usize,
    /// **D7.** Presence retry was active (sandboxed + both flags + no rollback).
    pub presence_retry_enabled: bool,
    /// **D7.** Hidden/invisible/inactive nodes considered under occur targets.
    pub presence_retry_candidates: usize,
    /// **D7.** Nodes admitted (Hidden/Invisible -> Visible).
    pub presence_retry_admitted: usize,
    /// **D7.** Candidates skipped (e.g. Inactive, fail-closed).
    pub presence_retry_skipped: usize,
    /// **D7.** Total nodes under admitted subtrees (recovery breadth).
    pub presence_retry_nodes_under_admitted: usize,
    /// **D7.** Field/draw nodes under admitted subtrees.
    pub presence_retry_text_nodes_admitted: usize,

    // ---- Epic A: runtime observability enrichment (XFA_FLATTEN_TRACE / XFA_RUNTIME_DIAG) ----
    /// **E-1 (XFA_FLATTEN_TRACE).** Per-script lifecycle entries (capped at 500).
    /// Each entry records the script index, host node id/name, activity, language,
    /// and outcome (executed|skipped_activity|skipped_mode|error|timeout).
    pub script_lifecycle: Vec<ScriptLifecycleEntry>,

    /// **E-6 (XFA_FLATTEN_TRACE).** Per-activity tally of JS scripts that were
    /// skipped (not routed through the sandbox).
    pub skipped_activities: SkippedActivities,

    /// **E-2 (XFA_RUNTIME_DIAG).** SOM resolution misses logged at the host-binding
    /// boundary. Capped at 200 entries.
    pub som_fail_log: Vec<SomFailEntry>,

    /// **E-3 (XFA_RUNTIME_DIAG).** Per-write instanceManager mutation log (capped at 200).
    pub instance_write_log: Vec<InstanceWriteEntry>,

    /// **E-4 (XFA_RUNTIME_DIAG).** Presence mutations observed during FormCalc
    /// script execution (capped at 200). JS-side presence writes go through the
    /// host binding; FormCalc goes through `set_presence` in dynamic.rs — this
    /// captures the FormCalc path.
    pub presence_mutation_log: Vec<PresenceMutationEntry>,

    /// **E-5 (XFA_FLATTEN_TRACE).** Number of named subforms hidden by
    /// `apply_form_dom_presence` because they had no matching form-DOM entry.
    pub form_dom_match_failures: usize,

    /// **E-5 (XFA_RUNTIME_DIAG).** Per-suppression entries from
    /// `apply_form_dom_presence` (capped at 200).
    pub form_dom_match_log: Vec<FormDomMatchEntry>,
}

// ---- Epic A support types ----

/// E-1: one entry in `script.lifecycle[]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScriptLifecycleEntry {
    /// Zero-based index of this script in document execution order.
    pub script_idx: usize,
    /// FormNodeId that owns the script.
    pub node_id: usize,
    /// `name` attribute of the owning node (may be empty).
    pub node_name: String,
    /// `activity` attribute of the event element (e.g. `"initialize"`), or empty.
    pub activity: String,
    /// Script language.
    pub lang: &'static str,
    /// Execution outcome.
    pub outcome: &'static str,
}

/// E-6: per-activity skip tallies for JavaScript scripts.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SkippedActivities {
    /// Scripts with `activity="initialize"` skipped.
    pub initialize: usize,
    /// Scripts with `activity="calculate"` skipped.
    pub calculate: usize,
    /// Scripts with `activity="click"` skipped.
    pub click: usize,
    /// Scripts with `activity="docReady"` skipped.
    pub doc_ready: usize,
    /// Scripts with `activity="layoutReady"` skipped.
    pub layout_ready: usize,
    /// Scripts with any other (or absent) activity attribute skipped.
    pub other: usize,
}

impl SkippedActivities {
    fn bump(&mut self, activity: Option<&str>) {
        match activity {
            Some("initialize") => self.initialize += 1,
            Some("calculate") => self.calculate += 1,
            Some("click") => self.click += 1,
            Some("docReady") => self.doc_ready += 1,
            Some("layoutReady") => self.layout_ready += 1,
            _ => self.other += 1,
        }
    }
}

/// E-2: one SOM-miss entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SomFailEntry {
    /// The SOM path that failed to resolve.
    pub path: String,
    /// Miss category (e.g. `"resolve_miss"`, `"formcalc_miss"`).
    pub kind: String,
    /// Script index (matches `ScriptLifecycleEntry::script_idx`).
    pub script_idx: usize,
    /// Activity at the time of the miss.
    pub activity: String,
}

/// E-3: one instanceManager write entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceWriteEntry {
    /// Script index.
    pub script_idx: usize,
    /// Activity.
    pub activity: String,
    /// Parent container node id.
    pub parent_node_id: usize,
    /// Parent container node name.
    pub parent_node_name: String,
    /// Prototype node name (the repeated child template).
    pub prototype_node_name: String,
    /// Instance count before the write.
    pub old_count: usize,
    /// Instance count after the write.
    pub new_count: usize,
}

/// E-4: one presence-mutation observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresenceMutationEntry {
    /// Script index at mutation time (best-effort; may be 0 for FormCalc).
    pub script_idx: usize,
    /// Activity at mutation time.
    pub activity: String,
    /// Node id.
    pub node_id: usize,
    /// Node name.
    pub node_name: String,
    /// Presence before the change.
    pub old_presence: String,
    /// Presence after the change.
    pub new_presence: String,
}

/// E-5: one form-DOM match-failure / suppression entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormDomMatchEntry {
    /// Template FormNodeId of the hidden subform.
    pub template_node_id: usize,
    /// Name of the suppressed subform.
    pub template_node_name: String,
    /// Short reason string.
    pub reason: String,
}

impl Default for DynamicScriptOutcome {
    fn default() -> Self {
        Self {
            changes: 0,
            js_present: false,
            js_skipped: 0,
            other_skipped: 0,
            formcalc_run: 0,
            formcalc_errors: 0,
            output_quality: OutputQuality::Exact,
            js_executed: 0,
            js_runtime_errors: 0,
            js_timeouts: 0,
            js_oom: 0,
            js_host_calls: 0,
            js_mutations: 0,
            js_instance_writes: 0,
            js_list_writes: 0,
            js_binding_errors: 0,
            js_resolve_failures: 0,
            js_data_reads: 0,
            js_unsupported_host_calls: 0,
            js_probe_skips: 0,
            variables_scripts_collected: 0,
            variables_data_items_collected: 0,
            script_objects_registered: 0,
            script_objects_register_failed: 0,
            script_objects_subform_scoped: 0,
            som_lookups_total: 0,
            som_lookup_successes: 0,
            som_lookup_failures: 0,
            som_lookup_ambiguous: 0,
            som_subform_scripts_exposed: 0,
            som_occur_path_refs: 0,
            occur_lookups_total: 0,
            occur_lookup_successes: 0,
            occur_lookup_failures: 0,
            occur_property_reads: 0,
            occur_property_writes: 0,
            occur_min_writes: 0,
            occur_max_writes: 0,
            occur_mutations_captured: 0,
            occur_mutations_applied: 0,
            occur_mutations_skipped: 0,
            occur_application_ambiguous: 0,
            occur_application_targets: 0,
            presence_retry_enabled: false,
            presence_retry_candidates: 0,
            presence_retry_admitted: 0,
            presence_retry_skipped: 0,
            presence_retry_nodes_under_admitted: 0,
            presence_retry_text_nodes_admitted: 0,
            script_lifecycle: Vec::new(),
            skipped_activities: SkippedActivities::default(),
            som_fail_log: Vec::new(),
            instance_write_log: Vec::new(),
            presence_mutation_log: Vec::new(),
            form_dom_match_failures: 0,
            form_dom_match_log: Vec::new(),
        }
    }
}

/// Snapshot of field values, presence states, and structural shape used for
/// rollback after a failed script pass.
///
/// XFA-INST-MGR (2026-05-17): the snapshot now also captures every node's
/// children list plus the total node count. When `restore_snapshot` runs it
/// truncates any clones added by `instanceManager.addInstance` /
/// `setInstances` and restores the original child ordering, so a rollback is
/// structurally consistent end-to-end — not just at the field-value layer.
/// This keeps the layout pass from seeing half-applied script mutations when
/// scripts produced enough errors to invalidate the pass.
///
/// NOTE: The rollback policy itself is our own heuristic — the XFA spec does
/// not define one. It protects against scripts that blank out all fields
/// (broken SOM resolution, etc.) or that add structural clones we cannot
/// safely keep after rejecting the pass.
struct FormSnapshot {
    field_values: Vec<(usize, String)>,
    presences: Vec<(usize, Presence)>,
    /// Per-node children list at snapshot time.  Indexed by `form.nodes`
    /// position; `children[i]` is the saved `children` vec for node `i`.
    children: Vec<Vec<FormNodeId>>,
    /// Total node count at snapshot time.  On rollback `form.nodes` and
    /// `form.metadata` are truncated back to this length, evicting any
    /// runtime-created clones from the form tree.
    node_count: usize,
    populated_count: usize,
}

fn snapshot_form(form: &FormTree) -> FormSnapshot {
    let mut field_values = Vec::new();
    let mut presences = Vec::new();
    let mut children = Vec::with_capacity(form.nodes.len());
    let mut populated_count = 0usize;
    for (idx, node) in form.nodes.iter().enumerate() {
        if let FormNodeType::Field { value } = &node.node_type {
            field_values.push((idx, value.clone()));
            if !value.trim().is_empty() {
                populated_count += 1;
            }
        }
        presences.push((idx, form.metadata[idx].presence));
        children.push(node.children.clone());
    }
    FormSnapshot {
        field_values,
        presences,
        children,
        node_count: form.nodes.len(),
        populated_count,
    }
}

fn restore_snapshot(form: &mut FormTree, snapshot: &FormSnapshot) {
    for (idx, value) in &snapshot.field_values {
        if let FormNodeType::Field { value: fv } = &mut form.nodes[*idx].node_type {
            *fv = value.clone();
        }
    }
    for (idx, presence) in &snapshot.presences {
        form.metadata[*idx].presence = *presence;
    }
    // XFA-INST-MGR: drop any runtime-created clones first, THEN restore the
    // original children lists.  Truncating must happen before assignment
    // because the saved children vec may reference indices that the snapshot
    // already covers (clones never receive an `xfa_id`, so `node_ids` does
    // not need pruning — see `host::clone_subtree`).
    if form.nodes.len() > snapshot.node_count {
        form.nodes.truncate(snapshot.node_count);
        form.metadata.truncate(snapshot.node_count);
    }
    for (idx, saved_children) in snapshot.children.iter().enumerate() {
        if let Some(node) = form.nodes.get_mut(idx) {
            node.children = saved_children.clone();
        }
    }
}

fn should_rollback(
    form: &FormTree,
    snapshot: &FormSnapshot,
    errors: usize,
    successes: usize,
) -> bool {
    if errors > 0 && errors > successes {
        return true;
    }
    if snapshot.populated_count >= 2 {
        let mut now_empty = 0usize;
        for (idx, old_value) in &snapshot.field_values {
            if old_value.trim().is_empty() {
                continue;
            }
            if let FormNodeType::Field { value } = &form.nodes[*idx].node_type {
                if value.trim().is_empty() {
                    now_empty += 1;
                }
            }
        }
        if now_empty * 2 > snapshot.populated_count {
            return true;
        }
    }
    false
}
// XFA Spec 3.3 §9.3 — Dynamic Forms: after data binding, scripts run in
// two phases: (1) initialize events fire once, (2) calculate events may
// iterate until stable (convergence) or MAX_SCRIPT_PASSES is reached.
// The spec (§14.3.2) defines the event model; our implementation runs
// initialize then calculate, matching Adobe's processing order.
//
// NOTE: §10.6 Rule 3 states the merge-completion order as:
//   value calcs → property calcs → validations → initialize events.
// Our order (initialize first) differs from the spec but matches Adobe's
// observed behavior on our 20K test corpus (97%+ SSIM). §28.2 (p1231)
// documents Adobe's event execution insert-at-position-2 algorithm.
//
// The default JavaScript handling is best-effort static flattening: parsed
// JavaScript and unsupported-language scripts are skipped and reported, while
// FormCalc continues to run. Use `apply_dynamic_scripts_with_mode(..., Strict)`
// when callers need the legacy whole-form JavaScript policy gate.

/// Convenience entry point: runs the script pipeline with the default
/// [`JsExecutionMode`] (currently [`JsExecutionMode::BestEffortStatic`]).
///
/// Prefer [`apply_dynamic_scripts_with_runtime`] when you need explicit
/// runtime injection (e.g. in tests or when using the sandboxed runtime).
#[doc(hidden)]
pub fn apply_dynamic_scripts(
    form: &mut FormTree,
    root_id: FormNodeId,
) -> Result<DynamicScriptOutcome> {
    apply_dynamic_scripts_with_mode(form, root_id, JsExecutionMode::default())
}

/// Runs the script pipeline with an explicit [`JsExecutionMode`], using the
/// internal [`NullRuntime`] (or the compiled-in QuickJS runtime for
/// [`JsExecutionMode::SandboxedRuntime`]).
///
/// This is an intermediate convenience wrapper. The canonical low-level entry
/// point is [`apply_dynamic_scripts_with_runtime`], which accepts any
/// [`XfaJsRuntime`] implementation.
#[doc(hidden)]
pub fn apply_dynamic_scripts_with_mode(
    form: &mut FormTree,
    root_id: FormNodeId,
    mode: JsExecutionMode,
) -> Result<DynamicScriptOutcome> {
    // M3-B Phase C-α (2026-05-03): when the caller asks for SandboxedRuntime
    // and the `xfa-js-sandboxed` feature is compiled in, instantiate the
    // real QuickJS-backed adapter. Without the feature, fall back to
    // NullRuntime which surfaces `SandboxError::NotCompiledIn` per script
    // (existing Phase B fallback semantics).
    #[cfg(feature = "xfa-js-sandboxed")]
    {
        if mode == JsExecutionMode::SandboxedRuntime {
            match crate::js_runtime::QuickJsRuntime::new() {
                Ok(mut rt) => {
                    return apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut rt);
                }
                Err(_e) => {
                    // QuickJS init failure is rare; fall through to NullRuntime
                    // so the dispatch path can still record per-script errors
                    // and the flatten succeeds in best-effort mode.
                }
            }
        }
    }
    apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut NullRuntime::new())
}

/// D6: opt-in gate for applying captured `occur.min` mutations to layout.
/// Default OFF. Even in sandboxed mode, occur application only happens when
/// `XFA_OCCUR_APPLY=1`, so the committed default (and default sandboxed
/// capture-only) behaviour is unchanged.
fn occur_apply_enabled() -> bool {
    std::env::var("XFA_OCCUR_APPLY").ok().as_deref() == Some("1")
}

/// D7: opt-in gate for presence/visibility retry. Default OFF. Requires
/// `XFA_PRESENCE_RETRY=1`; the dispatch path additionally requires
/// `XFA_OCCUR_APPLY=1` (occur application is the prerequisite for the retry).
fn presence_retry_enabled() -> bool {
    std::env::var("XFA_PRESENCE_RETRY").ok().as_deref() == Some("1")
}

/// Epic A: true when `XFA_RUNTIME_DIAG` is set to `"1"`.  Gates the verbose
/// per-entry log arrays (E-2, E-3, E-4, E-5-log).  Default OFF so normal
/// flattens are byte-identical and zero-cost.
pub(crate) fn runtime_diag_enabled() -> bool {
    std::env::var("XFA_RUNTIME_DIAG").ok().as_deref() == Some("1")
}

// Epic A E-4: thread-local accumulator for FormCalc presence mutations.
// Active only during `apply_dynamic_scripts_with_runtime` when
// `XFA_RUNTIME_DIAG=1`.  Using thread-local avoids threading an extra Vec
// through `run_script_phase` / `write_formcalc_value` / `set_presence`.
std::thread_local! {
    static PRESENCE_MUT_LOG: std::cell::RefCell<Option<Vec<PresenceMutationEntry>>> =
        const { std::cell::RefCell::new(None) };
}

/// Push a presence mutation entry into the thread-local log.
/// Called from `set_presence_inner` when an entry was created.
fn push_presence_mutation(
    node_id: usize,
    node_name: &str,
    old_presence: &'static str,
    new_presence: &'static str,
) {
    PRESENCE_MUT_LOG.with(|cell| {
        if let Some(ref mut log) = *cell.borrow_mut() {
            if log.len() < 200 {
                log.push(PresenceMutationEntry {
                    // script_idx/activity are not readily available at this call
                    // depth — report 0/"" (best-effort per Epic A spec note).
                    script_idx: 0,
                    activity: String::new(),
                    node_id,
                    node_name: node_name.to_string(),
                    old_presence: old_presence.to_string(),
                    new_presence: new_presence.to_string(),
                });
            }
        }
    });
}

/// Phase B entry point that lets the caller inject a sandboxed runtime
/// adapter. When `mode == JsExecutionMode::SandboxedRuntime` the supplied
/// `runtime` is consulted for every JavaScript script whose `<event activity>`
/// is in [`crate::js_runtime::SANDBOX_ACTIVITY_ALLOWLIST`]. UI / submission
/// activities skip the runtime entirely and are recorded as `js_skipped`,
/// matching `BestEffortStatic` behaviour for those scripts.
///
/// Other modes ignore `runtime` entirely; callers that just need the
/// existing strict / best-effort behaviour should use
/// [`apply_dynamic_scripts_with_mode`] (which routes through
/// [`crate::js_runtime::NullRuntime`]).
pub fn apply_dynamic_scripts_with_runtime(
    form: &mut FormTree,
    root_id: FormNodeId,
    mode: JsExecutionMode,
    runtime: &mut dyn XfaJsRuntime,
) -> Result<DynamicScriptOutcome> {
    let parents = build_parent_map(form, root_id);
    let all_scripts: Vec<(FormNodeId, Vec<EventScript>)> = form
        .nodes
        .iter()
        .enumerate()
        .filter_map(|(idx, _)| {
            let node_id = FormNodeId(idx);
            let scripts = form.meta(node_id).event_scripts.clone();
            (!scripts.is_empty()).then_some((node_id, scripts))
        })
        .collect();

    let has_unsupported_script = all_scripts.iter().any(|(_, node_scripts)| {
        node_scripts
            .iter()
            .any(|script| script.language != ScriptLanguage::FormCalc)
    });

    if mode == JsExecutionMode::Strict && has_unsupported_script {
        return Err(javascript_policy::reject_execution(
            JavaScriptEntryPoint::XfaEventHook,
        ));
    }

    let mut js_skipped = 0usize;
    let mut other_skipped = 0usize;
    let mut sandbox_metadata = RuntimeMetadata::default();
    let mut scripts = Vec::new();
    let sandbox_active = mode == JsExecutionMode::SandboxedRuntime;
    let snapshot = snapshot_form(form);

    // D1.B (XFA Product Quality Wave 3 — preSave gated allow). The env-var
    // gate is read ONCE per flatten so all scripts in this document see the
    // same decision. Default OFF; flipping requires
    // `XFA_PRESAVE_DURING_FLATTEN=1`. The host-binding layer is informed via
    // `set_presave_gate` so dispatch and host stay in lock-step (defence-
    // in-depth §2 of the policy doc).
    let presave_gate = sandbox_active && presave_during_flatten_enabled();

    // Epic A E-1/E-6: read gates once; zero cost unless env is set.
    let trace_enabled = crate::flatten_trace::enabled();
    let diag_enabled = runtime_diag_enabled();

    // E-1: lifecycle log (cap 500).
    let mut script_lifecycle: Vec<ScriptLifecycleEntry> = Vec::new();
    // E-6: per-activity skip tally.
    let mut skipped_activities = SkippedActivities::default();
    // Running script index (mirrors host.rs next_script_idx for JS; for
    // FormCalc scripts we share the same monotonic counter).
    let mut dispatch_script_idx: usize = 0;

    if sandbox_active {
        // Best-effort init / reset; init failures are non-fatal — the
        // dispatch path will record them as runtime_errors per script.
        let _ = runtime.init();
        let _ = runtime.reset_for_new_document();
        let _ = runtime.set_form_handle(form as *mut FormTree, root_id);
        runtime.set_presave_gate(presave_gate);
    }

    for (node_id, node_scripts) in all_scripts {
        let node = form.get(node_id);
        let node_name = if trace_enabled || diag_enabled {
            node.name.clone()
        } else {
            String::new()
        };
        let mut formcalc_scripts = Vec::new();
        for script in node_scripts {
            match script.language {
                ScriptLanguage::FormCalc => formcalc_scripts.push(script),
                ScriptLanguage::JavaScript => {
                    let activity_str = script.activity.as_deref().unwrap_or("");
                    if sandbox_active
                        && activity_allowed_for_sandbox_with_gate(
                            script.activity.as_deref(),
                            presave_gate,
                        )
                    {
                        let this_idx = dispatch_script_idx;
                        dispatch_script_idx += 1;
                        let _ = runtime.reset_per_script(node_id, script.activity.as_deref());
                        let outcome_str = match runtime
                            .execute_script(script.activity.as_deref(), &script.script)
                        {
                            Ok(_) => {
                                // Counter increment lives on `take_metadata()`.
                                "executed"
                            }
                            Err(SandboxError::Timeout) => {
                                js_skipped += 1;
                                "timeout"
                            }
                            Err(SandboxError::OutOfMemory) => {
                                js_skipped += 1;
                                "error"
                            }
                            Err(e) => {
                                // M3-B Phase C-α: surface the per-script error
                                // class via `log::debug!` for normal builds and
                                // also via stderr when `XFA_JS_DEBUG=1` is set
                                // (operator triage aid; xfa-cli does not init
                                // a logger that respects RUST_LOG).
                                log::debug!(
                                    "sandbox script error on activity={:?}: {}",
                                    script.activity.as_deref(),
                                    e
                                );
                                if std::env::var("XFA_JS_DEBUG").ok().as_deref() == Some("1") {
                                    eprintln!(
                                        "XFA_JS_DEBUG sandbox script error on activity={:?}: {}",
                                        script.activity.as_deref(),
                                        e
                                    );
                                }
                                js_skipped += 1;
                                "error"
                            }
                        };
                        // E-1: record lifecycle entry when tracing is on.
                        if trace_enabled && script_lifecycle.len() < 500 {
                            script_lifecycle.push(ScriptLifecycleEntry {
                                script_idx: this_idx,
                                node_id: node_id.0,
                                node_name: node_name.clone(),
                                activity: activity_str.to_string(),
                                lang: "javascript",
                                outcome: outcome_str,
                            });
                        }
                    } else {
                        js_skipped += 1;
                        // E-1: record skipped lifecycle entry.
                        if trace_enabled && script_lifecycle.len() < 500 {
                            let skip_reason = if sandbox_active {
                                "skipped_activity"
                            } else {
                                "skipped_mode"
                            };
                            script_lifecycle.push(ScriptLifecycleEntry {
                                script_idx: dispatch_script_idx,
                                node_id: node_id.0,
                                node_name: node_name.clone(),
                                activity: activity_str.to_string(),
                                lang: "javascript",
                                outcome: skip_reason,
                            });
                        }
                        // E-6: tally per-activity skips.
                        if trace_enabled {
                            skipped_activities.bump(script.activity.as_deref());
                        }
                        dispatch_script_idx += 1;
                    }
                }
                ScriptLanguage::Other => {
                    other_skipped += 1;
                    // E-1: log other-language skips.
                    if trace_enabled && script_lifecycle.len() < 500 {
                        script_lifecycle.push(ScriptLifecycleEntry {
                            script_idx: dispatch_script_idx,
                            node_id: node_id.0,
                            node_name: node_name.clone(),
                            activity: script.activity.as_deref().unwrap_or("").to_string(),
                            lang: "other",
                            outcome: "skipped_mode",
                        });
                    }
                    dispatch_script_idx += 1;
                }
            }
        }
        if !formcalc_scripts.is_empty() {
            scripts.push((node_id, formcalc_scripts));
        }
    }

    let mut captured_occur: Vec<(usize, String, i64)> = Vec::new();
    // Epic A E-2/E-3: logs drained from host bindings.
    let mut sandbox_diag = crate::js_runtime::RuntimeDiagLogs::default();
    if sandbox_active {
        let _ = runtime.set_form_handle(std::ptr::null_mut(), root_id);
        sandbox_metadata = runtime.take_metadata();
        captured_occur = runtime.take_occur_mutations();
        if diag_enabled {
            sandbox_diag = runtime.take_diag_logs();
        }
    }

    let mut stats = ScriptStats::default();

    // Epic A E-4: arm the thread-local presence mutation log.
    if diag_enabled {
        PRESENCE_MUT_LOG.with(|cell| {
            *cell.borrow_mut() = Some(Vec::new());
        });
    }

    let mut changes = sandbox_metadata
        .mutations
        .saturating_add(sandbox_metadata.instance_writes)
        + run_script_phase(
            form,
            root_id,
            &parents,
            &scripts,
            ScriptPhase::Initialize,
            1,
            &mut stats,
        )?
        + run_script_phase(
            form,
            root_id,
            &parents,
            &scripts,
            ScriptPhase::Calculate,
            MAX_SCRIPT_PASSES,
            &mut stats,
        )?;

    // E-4: drain and disarm.
    let presence_mutation_log = if diag_enabled {
        PRESENCE_MUT_LOG.with(|cell| cell.borrow_mut().take().unwrap_or_default())
    } else {
        Vec::new()
    };

    let sandbox_rollback_errors = sandbox_metadata
        .runtime_errors
        .saturating_add(sandbox_metadata.timeouts)
        .saturating_add(sandbox_metadata.oom)
        .saturating_add(sandbox_metadata.binding_errors);
    let rollback_errors = stats.errors.saturating_add(sandbox_rollback_errors);
    let rollback_successes = stats.successes.saturating_add(sandbox_metadata.executed);

    let rolled_back = should_rollback(form, &snapshot, rollback_errors, rollback_successes);
    if rolled_back {
        restore_snapshot(form, &snapshot);
        changes = 0;
    }

    // D6: apply captured `occur.min` mutations to the form before layout.
    // Strictly gated: sandboxed mode + the opt-in `XFA_OCCUR_APPLY=1` flag +
    // the script pass did NOT roll back. Default flatten and the default
    // sandboxed (capture-only) behaviour are unchanged. Each captured write is
    // applied only to a live, repeatable container node (Subform/Area/ExclGroup);
    // everything else fails closed (counted, not applied). `occur.max` writes
    // are captured but not applied in D6 (min-only scope).
    let mut occur_applied_targets: std::collections::HashSet<usize> =
        std::collections::HashSet::new();
    if sandbox_active && !rolled_back && occur_apply_enabled() && !captured_occur.is_empty() {
        for (idx, prop, value) in &captured_occur {
            if prop != "min" {
                // occur.max captured but not applied in D6.
                sandbox_metadata.occur_mutations_skipped =
                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
                continue;
            }
            if *value < 0 || *idx >= form.nodes.len() {
                sandbox_metadata.occur_mutations_skipped =
                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
                continue;
            }
            let nid = FormNodeId(*idx);
            let is_repeatable = matches!(
                form.get(nid).node_type,
                FormNodeType::Subform | FormNodeType::Area | FormNodeType::ExclGroup
            );
            if !is_repeatable {
                // Fail closed: only repeatable containers may have occur applied.
                sandbox_metadata.occur_application_ambiguous = sandbox_metadata
                    .occur_application_ambiguous
                    .saturating_add(1);
                sandbox_metadata.occur_mutations_skipped =
                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
                continue;
            }
            let v = *value as u32;
            let node = form.get_mut(nid);
            node.occur.min = v;
            if node.occur.initial < v {
                node.occur.initial = v;
            }
            if let Some(m) = node.occur.max {
                if m < v {
                    node.occur.max = Some(v);
                }
            }
            sandbox_metadata.occur_mutations_applied =
                sandbox_metadata.occur_mutations_applied.saturating_add(1);
            occur_applied_targets.insert(*idx);
        }
        sandbox_metadata.occur_application_targets = sandbox_metadata
            .occur_application_targets
            .saturating_add(occur_applied_targets.len());
    } else if !captured_occur.is_empty() {
        // Captured but the apply gate is closed (default capture-only path):
        // count them as skipped for observability without touching layout.
        sandbox_metadata.occur_mutations_skipped = sandbox_metadata
            .occur_mutations_skipped
            .saturating_add(captured_occur.len());
    }

    // D7: opt-in presence/visibility retry. Strictly gated: sandboxed +
    // `XFA_PRESENCE_RETRY=1` + `XFA_OCCUR_APPLY=1` (occur application is the
    // prerequisite) + no rollback. Admits (`Hidden`/`Invisible` -> `Visible`)
    // ONLY nodes that are an occur-applied target or a descendant of one
    // (occur-related, unambiguous). `Inactive` is skipped (fail-closed). Default
    // and default-sandboxed behaviour are unchanged.
    let mut pr_candidates = 0usize;
    let mut pr_admitted = 0usize;
    let mut pr_skipped = 0usize;
    let mut pr_nodes_under_admitted = 0usize;
    let mut pr_text_nodes_admitted = 0usize;
    let presence_retry_active = sandbox_active
        && !rolled_back
        && occur_apply_enabled()
        && presence_retry_enabled()
        && !occur_applied_targets.is_empty();
    if presence_retry_active {
        let mut visited: std::collections::HashSet<usize> = std::collections::HashSet::new();
        let mut stack: Vec<FormNodeId> = occur_applied_targets
            .iter()
            .map(|&i| FormNodeId(i))
            .collect();
        let mut admitted_ids: std::collections::HashSet<usize> = std::collections::HashSet::new();
        while let Some(nid) = stack.pop() {
            if nid.0 >= form.nodes.len() || !visited.insert(nid.0) {
                continue;
            }
            let presence = form.meta(nid).presence;
            if presence == Presence::Hidden || presence == Presence::Invisible {
                pr_candidates += 1;
                // Fail-closed safety: never un-hide `Inactive` (intentionally
                // removed); only `Hidden`/`Invisible` are admitted here.
                form.meta_mut(nid).presence = Presence::Visible;
                pr_admitted += 1;
                admitted_ids.insert(nid.0);
            } else if presence == Presence::Inactive {
                pr_candidates += 1;
                pr_skipped += 1;
            }
            for &c in &form.get(nid).children {
                stack.push(c);
            }
        }
        // Count content under admitted subtrees (observability of recovery).
        for &aid in &admitted_ids {
            let mut s = vec![FormNodeId(aid)];
            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
            while let Some(n) = s.pop() {
                if n.0 >= form.nodes.len() || !seen.insert(n.0) {
                    continue;
                }
                pr_nodes_under_admitted += 1;
                if let FormNodeType::Draw(_) | FormNodeType::Field { .. } = form.get(n).node_type {
                    pr_text_nodes_admitted += 1;
                }
                for &c in &form.get(n).children {
                    s.push(c);
                }
            }
        }
    }

    let js_seen_count = js_skipped + sandbox_metadata.executed;
    let js_present = js_seen_count > 0;
    let output_quality = if sandbox_active && js_present && sandbox_metadata.is_clean() {
        OutputQuality::Sandboxed
    } else if (sandbox_active && js_present) || js_skipped > 0 || other_skipped > 0 {
        OutputQuality::BestEffort
    } else {
        OutputQuality::Exact
    };

    Ok(DynamicScriptOutcome {
        changes,
        js_present,
        js_skipped,
        other_skipped,
        formcalc_run: stats.formcalc_run,
        formcalc_errors: stats.formcalc_errors,
        output_quality,
        js_executed: sandbox_metadata.executed,
        js_runtime_errors: sandbox_metadata.runtime_errors,
        js_timeouts: sandbox_metadata.timeouts,
        js_oom: sandbox_metadata.oom,
        js_host_calls: sandbox_metadata.host_calls,
        js_mutations: sandbox_metadata.mutations,
        js_instance_writes: sandbox_metadata.instance_writes,
        js_list_writes: sandbox_metadata.list_writes,
        js_binding_errors: sandbox_metadata.binding_errors,
        js_resolve_failures: sandbox_metadata.resolve_failures,
        js_data_reads: sandbox_metadata.data_reads,
        js_unsupported_host_calls: sandbox_metadata.unsupported_host_calls,
        js_probe_skips: sandbox_metadata.probe_skips,
        variables_scripts_collected: sandbox_metadata.variables_scripts_collected,
        variables_data_items_collected: sandbox_metadata.variables_data_items_collected,
        script_objects_registered: sandbox_metadata.script_objects_registered,
        script_objects_register_failed: sandbox_metadata.script_objects_register_failed,
        script_objects_subform_scoped: sandbox_metadata.script_objects_subform_scoped,
        som_lookups_total: sandbox_metadata.som_lookups_total,
        som_lookup_successes: sandbox_metadata.som_lookup_successes,
        som_lookup_failures: sandbox_metadata.som_lookup_failures,
        som_lookup_ambiguous: sandbox_metadata.som_lookup_ambiguous,
        som_subform_scripts_exposed: sandbox_metadata.som_subform_scripts_exposed,
        som_occur_path_refs: sandbox_metadata.som_occur_path_refs,
        occur_lookups_total: sandbox_metadata.occur_lookups_total,
        occur_lookup_successes: sandbox_metadata.occur_lookup_successes,
        occur_lookup_failures: sandbox_metadata.occur_lookup_failures,
        occur_property_reads: sandbox_metadata.occur_property_reads,
        occur_property_writes: sandbox_metadata.occur_property_writes,
        occur_min_writes: sandbox_metadata.occur_min_writes,
        occur_max_writes: sandbox_metadata.occur_max_writes,
        occur_mutations_captured: sandbox_metadata.occur_mutations_captured,
        occur_mutations_applied: sandbox_metadata.occur_mutations_applied,
        occur_mutations_skipped: sandbox_metadata.occur_mutations_skipped,
        occur_application_ambiguous: sandbox_metadata.occur_application_ambiguous,
        occur_application_targets: sandbox_metadata.occur_application_targets,
        presence_retry_enabled: presence_retry_active,
        presence_retry_candidates: pr_candidates,
        presence_retry_admitted: pr_admitted,
        presence_retry_skipped: pr_skipped,
        presence_retry_nodes_under_admitted: pr_nodes_under_admitted,
        presence_retry_text_nodes_admitted: pr_text_nodes_admitted,
        // Epic A fields.
        script_lifecycle,
        skipped_activities,
        som_fail_log: sandbox_diag.som_fail_log,
        instance_write_log: sandbox_diag.instance_write_log,
        presence_mutation_log,
        form_dom_match_failures: 0,
        form_dom_match_log: Vec::new(),
    })
}

fn has_hidden_ancestor(
    form: &FormTree,
    parents: &HashMap<FormNodeId, FormNodeId>,
    node_id: FormNodeId,
) -> bool {
    let mut cursor = parents.get(&node_id).copied();
    while let Some(ancestor) = cursor {
        if form.meta(ancestor).presence.is_not_visible() {
            return true;
        }
        cursor = parents.get(&ancestor).copied();
    }
    false
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScriptPhase {
    Initialize,
    Calculate,
}

#[derive(Default)]
struct ScriptStats {
    errors: usize,
    successes: usize,
    formcalc_run: usize,
    formcalc_errors: usize,
}

#[derive(Debug)]
struct ScriptResult {
    changes: usize,
    error: bool,
}

fn run_script_phase(
    form: &mut FormTree,
    root_id: FormNodeId,
    parents: &HashMap<FormNodeId, FormNodeId>,
    scripts: &[(FormNodeId, Vec<EventScript>)],
    phase: ScriptPhase,
    max_passes: usize,
    stats: &mut ScriptStats,
) -> Result<usize> {
    let mut total_changes = 0;

    for _ in 0..max_passes {
        let mut pass_changes = 0;

        for (node_id, node_scripts) in scripts {
            if has_hidden_ancestor(form, parents, *node_id) {
                continue;
            }

            for script in node_scripts
                .iter()
                .filter(|script| should_run_script(script, phase))
            {
                let result = execute_event_script(form, root_id, parents, *node_id, script, phase)?;
                stats.formcalc_run += 1;
                if result.error {
                    stats.errors += 1;
                    stats.formcalc_errors += 1;
                } else {
                    stats.successes += 1;
                }
                pass_changes += result.changes;
            }
        }

        total_changes += pass_changes;
        if pass_changes == 0 {
            break;
        }
    }

    Ok(total_changes)
}

fn should_run_script(script: &EventScript, phase: ScriptPhase) -> bool {
    match phase {
        ScriptPhase::Initialize => script.activity.as_deref() == Some("initialize"),
        ScriptPhase::Calculate => script.activity.as_deref() == Some("calculate"),
    }
}

fn execute_event_script(
    form: &mut FormTree,
    root_id: FormNodeId,
    parents: &HashMap<FormNodeId, FormNodeId>,
    current_id: FormNodeId,
    script: &EventScript,
    phase: ScriptPhase,
) -> Result<ScriptResult> {
    match script.language {
        ScriptLanguage::FormCalc => Ok(execute_formcalc_script(
            form, root_id, parents, current_id, script, phase,
        )),
        ScriptLanguage::JavaScript => Err(javascript_policy::reject_execution(
            JavaScriptEntryPoint::XfaEventHook,
        )),
        ScriptLanguage::Other => Err(XfaError::UnsupportedFeature("script language".to_string())),
    }
}

/// Emit a stderr line when `XFA_FORMCALC_DEBUG=1` is set so that residual-scan
/// tooling can aggregate FormCalc failures by stage (`lexer` / `parser` /
/// `interpreter`) and message. Off by default; emits nothing otherwise.
///
/// Mirrors the `XFA_JS_DEBUG` opt-in pattern used by the sandbox JS runtime.
fn formcalc_debug_emit(stage: &str, message: &str, script: &EventScript) {
    if std::env::var("XFA_FORMCALC_DEBUG").ok().as_deref() != Some("1") {
        return;
    }
    let activity = script.activity.as_deref().unwrap_or("?");
    // Single-line, double-quoted message so the scan script's regex can parse
    // it like `XFA_JS_DEBUG resolve_*` lines. Newlines collapsed defensively.
    let one_line = message.replace(['\n', '\r'], " ");
    eprintln!("XFA_FORMCALC_DEBUG stage={stage} activity=\"{activity}\" message=\"{one_line}\"");
}

fn execute_formcalc_script(
    form: &mut FormTree,
    root_id: FormNodeId,
    parents: &HashMap<FormNodeId, FormNodeId>,
    current_id: FormNodeId,
    script: &EventScript,
    phase: ScriptPhase,
) -> ScriptResult {
    let tokens = match tokenize(&script.script) {
        Ok(t) => t,
        Err(err) => {
            formcalc_debug_emit("lexer", &format!("{err}"), script);
            return ScriptResult {
                changes: 0,
                error: true,
            };
        }
    };
    let ast = match parser::parse(tokens) {
        Ok(a) => a,
        Err(err) => {
            formcalc_debug_emit("parser", &format!("{err}"), script);
            return ScriptResult {
                changes: 0,
                error: true,
            };
        }
    };

    let mut interpreter = Interpreter::new();
    let mut resolver = FormTreeSomResolver::new(form, root_id, parents, current_id);
    let result = match interpreter.exec_with_resolver(&ast, &mut resolver) {
        Ok(r) => r,
        Err(err) => {
            formcalc_debug_emit("interpreter", &format!("{err}"), script);
            return ScriptResult {
                changes: resolver.changes,
                error: true,
            };
        }
    };

    if matches!(phase, ScriptPhase::Calculate) {
        resolver.changes += write_formcalc_value(
            resolver.form,
            current_id,
            ResolvedProperty::RawValue,
            result,
        );
    }

    ScriptResult {
        changes: resolver.changes,
        error: false,
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResolvedProperty {
    RawValue,
    Presence,
    SomExpression,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ResolvedTarget {
    node_id: FormNodeId,
    property: ResolvedProperty,
}

struct FormTreeSomResolver<'a> {
    form: &'a mut FormTree,
    root_id: FormNodeId,
    parents: &'a HashMap<FormNodeId, FormNodeId>,
    current_id: FormNodeId,
    changes: usize,
}

impl<'a> FormTreeSomResolver<'a> {
    fn new(
        form: &'a mut FormTree,
        root_id: FormNodeId,
        parents: &'a HashMap<FormNodeId, FormNodeId>,
        current_id: FormNodeId,
    ) -> Self {
        Self {
            form,
            root_id,
            parents,
            current_id,
            changes: 0,
        }
    }

    fn resolve_target(&self, path: &str) -> Option<ResolvedTarget> {
        let trimmed = path.trim();
        if trimmed.is_empty() {
            return None;
        }

        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
            return Some(ResolvedTarget {
                node_id: self.current_id,
                property: parse_property_name(trimmed)?,
            });
        }

        let (expr, property) = split_property_path(trimmed)?;
        let node_id = self.resolve_expression(&expr)?.into_iter().next()?;
        Some(ResolvedTarget { node_id, property })
    }

    fn count_targets(&self, path: &str) -> usize {
        let trimmed = path.trim();
        if trimmed.is_empty() {
            return 0;
        }
        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
            return 1;
        }
        let Some((expr, _property)) = split_property_path(trimmed) else {
            return 0;
        };
        self.resolve_expression(&expr)
            .map_or(0, |nodes| nodes.len())
    }

    fn resolve_expression(&self, expr: &SomExpression) -> Option<Vec<FormNodeId>> {
        match expr.root {
            SomRoot::Data | SomRoot::Record | SomRoot::Template => None,
            SomRoot::CurrentContainer => {
                if expr.segments.is_empty() {
                    Some(vec![self.current_id])
                } else {
                    Some(self.follow_absolute(vec![self.current_id], &expr.segments))
                }
            }
            SomRoot::Form => {
                if expr.segments.is_empty() {
                    Some(vec![self.root_id])
                } else {
                    Some(self.follow_absolute(vec![self.root_id], &expr.segments))
                }
            }
            SomRoot::Xfa => {
                let segments = strip_xfa_form_prefix(&expr.segments);
                if segments.is_empty() {
                    Some(vec![self.root_id])
                } else {
                    Some(self.follow_absolute(vec![self.root_id], segments))
                }
            }
            SomRoot::Unqualified => {
                if expr.segments.is_empty() {
                    Some(vec![self.current_id])
                } else {
                    Some(self.follow_unqualified(&expr.segments))
                }
            }
        }
    }

    fn follow_absolute(
        &self,
        mut current: Vec<FormNodeId>,
        segments: &[xfa_dom_resolver::som::SomSegment],
    ) -> Vec<FormNodeId> {
        for (idx, segment) in segments.iter().enumerate() {
            let allow_self = idx == 0;
            current = current
                .into_iter()
                .flat_map(|node_id| self.step_from_node(node_id, segment, allow_self))
                .collect();
            if current.is_empty() {
                break;
            }
        }
        current
    }

    fn follow_unqualified(
        &self,
        segments: &[xfa_dom_resolver::som::SomSegment],
    ) -> Vec<FormNodeId> {
        let Some((first, rest)) = segments.split_first() else {
            return vec![self.current_id];
        };

        let mut scope = Some(self.current_id);
        while let Some(scope_id) = scope {
            let anchors: Vec<_> = descendants_inclusive(self.form, scope_id)
                .into_iter()
                .filter(|node_id| self.node_matches_segment(*node_id, first))
                .collect();
            let matched = self.follow_remaining(anchors, rest);
            if !matched.is_empty() {
                return matched;
            }
            scope = self.parents.get(&scope_id).copied();
        }

        let anchors: Vec<_> = descendants_inclusive(self.form, self.root_id)
            .into_iter()
            .filter(|node_id| self.node_matches_segment(*node_id, first))
            .collect();
        self.follow_remaining(anchors, rest)
    }

    fn follow_remaining(
        &self,
        mut current: Vec<FormNodeId>,
        segments: &[xfa_dom_resolver::som::SomSegment],
    ) -> Vec<FormNodeId> {
        for segment in segments {
            current = current
                .into_iter()
                .flat_map(|node_id| self.step_from_node(node_id, segment, false))
                .collect();
            if current.is_empty() {
                break;
            }
        }
        current
    }

    fn step_from_node(
        &self,
        node_id: FormNodeId,
        segment: &xfa_dom_resolver::som::SomSegment,
        allow_self: bool,
    ) -> Vec<FormNodeId> {
        // XFA-F3-06: `..` (parent) navigation — a segment whose name is an
        // empty string (produced by the `.` separator after `..` in the raw path)
        // or literally ".." navigates to the parent node.
        if let SomSelector::Name(name) = &segment.selector {
            if name == ".." {
                // Navigate to parent
                if let Some(&parent_id) = self.parents.get(&node_id) {
                    return apply_index_to_single(parent_id, segment.index);
                }
                return Vec::new();
            }
        }

        if allow_self && self.node_matches_selector(node_id, &segment.selector) {
            return apply_index_to_single(node_id, segment.index);
        }

        let matches: Vec<_> = self
            .form
            .get(node_id)
            .children
            .iter()
            .copied()
            .filter(|child_id| self.node_matches_selector(*child_id, &segment.selector))
            .collect();

        apply_index(matches, segment.index)
    }

    fn node_matches_segment(
        &self,
        node_id: FormNodeId,
        segment: &xfa_dom_resolver::som::SomSegment,
    ) -> bool {
        if !self.node_matches_selector(node_id, &segment.selector) {
            return false;
        }

        match segment.index {
            SomIndex::All => true,
            SomIndex::None => self.sibling_position(node_id, &segment.selector) == Some(0),
            SomIndex::Specific(idx) => {
                self.sibling_position(node_id, &segment.selector) == Some(idx)
            }
        }
    }

    fn sibling_position(&self, node_id: FormNodeId, selector: &SomSelector) -> Option<usize> {
        let Some(parent_id) = self.parents.get(&node_id).copied() else {
            return self.node_matches_selector(node_id, selector).then_some(0);
        };

        self.form
            .get(parent_id)
            .children
            .iter()
            .copied()
            .filter(|candidate| self.node_matches_selector(*candidate, selector))
            .position(|candidate| candidate == node_id)
    }

    fn node_matches_selector(&self, node_id: FormNodeId, selector: &SomSelector) -> bool {
        match selector {
            SomSelector::Name(name) => self.form.get(node_id).name == *name,
            SomSelector::Class(class_name) => self.node_matches_class(node_id, class_name),
            SomSelector::AllChildren => true,
        }
    }

    fn node_matches_class(&self, node_id: FormNodeId, class_name: &str) -> bool {
        let class_name = class_name.to_ascii_lowercase();
        match class_name.as_str() {
            "subform" => matches!(
                self.form.get(node_id).node_type,
                FormNodeType::Root | FormNodeType::Subform
            ),
            "pageset" => {
                matches!(self.form.get(node_id).node_type, FormNodeType::PageSet)
            }
            "pagearea" => matches!(
                self.form.get(node_id).node_type,
                FormNodeType::PageArea { .. }
            ),
            "field" => matches!(self.form.get(node_id).node_type, FormNodeType::Field { .. }),
            "draw" => matches!(
                self.form.get(node_id).node_type,
                FormNodeType::Draw(_) | FormNodeType::Image { .. }
            ),
            "exclgroup" => self.form.meta(node_id).group_kind == GroupKind::ExclusiveChoice,
            _ => false,
        }
    }
}

impl SomResolver for FormTreeSomResolver<'_> {
    fn resolve_path(
        &mut self,
        path: &str,
    ) -> formcalc_interpreter::error::Result<Option<FormCalcValue>> {
        let Some(target) = self.resolve_target(path) else {
            // XFA-F3-06: log a warning instead of silently returning None so
            // that SOM path failures are diagnosable.
            if !path.trim().is_empty() {
                log::warn!("SOM bridge: path not resolved: {:?}", path.trim());
            }
            return Ok(None);
        };
        Ok(Some(read_formcalc_value(
            self.form,
            self.root_id,
            self.parents,
            target,
        )))
    }

    fn assign_path(
        &mut self,
        path: &str,
        value: FormCalcValue,
    ) -> formcalc_interpreter::error::Result<bool> {
        let Some(target) = self.resolve_target(path) else {
            // XFA-F3-06: descriptive warning on assignment failure.
            if !path.trim().is_empty() {
                log::warn!("SOM bridge: assignment target not found: {:?}", path.trim());
            }
            return Ok(false);
        };
        self.changes += write_formcalc_value(self.form, target.node_id, target.property, value);
        Ok(true)
    }

    fn count_path_matches(&mut self, path: &str) -> formcalc_interpreter::error::Result<usize> {
        Ok(self.count_targets(path))
    }
}

fn split_property_path(path: &str) -> Option<(SomExpression, ResolvedProperty)> {
    let normalized = if let Some(rest) = path.strip_prefix("this.") {
        format!("$.{rest}")
    } else if path == "this" {
        "$".to_string()
    } else {
        path.to_string()
    };

    let mut expr = parse_som(&normalized).ok()?;
    let property = if let Some(last) = expr.segments.last() {
        match &last.selector {
            SomSelector::Name(name) => {
                parse_property_name(name).unwrap_or(ResolvedProperty::RawValue)
            }
            _ => ResolvedProperty::RawValue,
        }
    } else {
        ResolvedProperty::RawValue
    };

    if matches!(
        expr.segments.last().map(|segment| &segment.selector),
        Some(SomSelector::Name(name)) if parse_property_name(name).is_some()
    ) {
        expr.segments.pop();
    }

    Some((expr, property))
}

fn parse_property_name(name: &str) -> Option<ResolvedProperty> {
    match name {
        "rawValue" => Some(ResolvedProperty::RawValue),
        "presence" => Some(ResolvedProperty::Presence),
        "somExpression" => Some(ResolvedProperty::SomExpression),
        _ => None,
    }
}

fn strip_xfa_form_prefix(
    segments: &[xfa_dom_resolver::som::SomSegment],
) -> &[xfa_dom_resolver::som::SomSegment] {
    match segments.first() {
        Some(segment)
            if matches!(&segment.selector, SomSelector::Name(name) if name == "form")
                && matches!(segment.index, SomIndex::None) =>
        {
            &segments[1..]
        }
        _ => segments,
    }
}

fn apply_index(matches: Vec<FormNodeId>, index: SomIndex) -> Vec<FormNodeId> {
    match index {
        SomIndex::None => matches.into_iter().take(1).collect(),
        SomIndex::Specific(idx) => matches.get(idx).copied().into_iter().collect(),
        SomIndex::All => matches,
    }
}

fn apply_index_to_single(node_id: FormNodeId, index: SomIndex) -> Vec<FormNodeId> {
    match index {
        SomIndex::None | SomIndex::Specific(0) | SomIndex::All => vec![node_id],
        SomIndex::Specific(_) => Vec::new(),
    }
}

fn read_formcalc_value(
    form: &FormTree,
    root_id: FormNodeId,
    parents: &HashMap<FormNodeId, FormNodeId>,
    target: ResolvedTarget,
) -> FormCalcValue {
    match target.property {
        ResolvedProperty::RawValue => get_formcalc_raw_value(form, target.node_id),
        ResolvedProperty::Presence => FormCalcValue::String(
            match form.meta(target.node_id).presence {
                Presence::Visible => "visible",
                Presence::Hidden => "hidden",
                Presence::Invisible => "invisible",
                Presence::Inactive => "inactive",
            }
            .to_string(),
        ),
        ResolvedProperty::SomExpression => {
            FormCalcValue::String(build_som_expression(form, root_id, parents, target.node_id))
        }
    }
}

fn get_formcalc_raw_value(form: &FormTree, node_id: FormNodeId) -> FormCalcValue {
    match &form.get(node_id).node_type {
        FormNodeType::Field { value } => string_to_formcalc_value(value),
        _ if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice => {
            for &child_id in &form.get(node_id).children {
                if let FormNodeType::Field { value } = &form.get(child_id).node_type {
                    if !value.is_empty() {
                        let selected = form.meta(child_id).item_value.as_deref().unwrap_or(value);
                        return string_to_formcalc_value(selected);
                    }
                }
            }
            FormCalcValue::Null
        }
        _ => FormCalcValue::Null,
    }
}

fn write_formcalc_value(
    form: &mut FormTree,
    node_id: FormNodeId,
    property: ResolvedProperty,
    value: FormCalcValue,
) -> usize {
    match property {
        ResolvedProperty::RawValue => set_raw_value(form, node_id, formcalc_to_script_value(value)),
        ResolvedProperty::Presence => {
            set_presence(form, node_id, ScriptValue::String(value.to_string_val()))
        }
        ResolvedProperty::SomExpression => 0,
    }
}

fn string_to_formcalc_value(value: &str) -> FormCalcValue {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        FormCalcValue::Null
    } else if let Ok(number) = trimmed.parse::<f64>() {
        FormCalcValue::Number(number)
    } else {
        FormCalcValue::String(value.to_string())
    }
}

fn formcalc_to_script_value(value: FormCalcValue) -> ScriptValue {
    match value {
        FormCalcValue::Null => ScriptValue::Null,
        FormCalcValue::Number(number) => ScriptValue::String(normalize_number(number)),
        FormCalcValue::String(value) => ScriptValue::String(value),
    }
}

fn build_som_expression(
    form: &FormTree,
    root_id: FormNodeId,
    parents: &HashMap<FormNodeId, FormNodeId>,
    node_id: FormNodeId,
) -> String {
    let mut parts = Vec::new();
    let mut cursor = Some(node_id);
    while let Some(current) = cursor {
        let node = form.get(current);
        if !node.name.is_empty() {
            let index = if let Some(parent_id) = parents.get(&current).copied() {
                form.get(parent_id)
                    .children
                    .iter()
                    .copied()
                    .filter(|sibling_id| form.get(*sibling_id).name == node.name)
                    .position(|sibling_id| sibling_id == current)
                    .unwrap_or(0)
            } else {
                0
            };
            parts.push(format!("{}[{index}]", node.name));
        }
        if current == root_id {
            break;
        }
        cursor = parents.get(&current).copied();
    }
    parts.reverse();

    if parts.is_empty() {
        "$form".to_string()
    } else {
        format!("$form.{}", parts.join("."))
    }
}

fn descendants_inclusive(form: &FormTree, root_id: FormNodeId) -> Vec<FormNodeId> {
    let mut out = Vec::new();
    collect_descendants(form, root_id, &mut out);
    out
}

fn collect_descendants(form: &FormTree, node_id: FormNodeId, out: &mut Vec<FormNodeId>) {
    out.push(node_id);
    for &child_id in &form.get(node_id).children {
        collect_descendants(form, child_id, out);
    }
}

fn build_parent_map(form: &FormTree, root_id: FormNodeId) -> HashMap<FormNodeId, FormNodeId> {
    let mut parents = HashMap::new();
    populate_parent_map(form, root_id, &mut parents);
    parents
}

fn populate_parent_map(
    form: &FormTree,
    node_id: FormNodeId,
    parents: &mut HashMap<FormNodeId, FormNodeId>,
) {
    for &child_id in &form.get(node_id).children {
        parents.insert(child_id, node_id);
        populate_parent_map(form, child_id, parents);
    }
}

fn set_raw_value(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
    let value = match value {
        ScriptValue::Null => String::new(),
        ScriptValue::String(value) => value,
    };

    if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice {
        let mut changes = 0;
        for &child_id in &form.get(node_id).children.clone() {
            let item_value = form.meta(child_id).item_value.clone();
            let next = if item_value.as_deref() == Some(value.as_str()) {
                value.clone()
            } else {
                String::new()
            };
            if let FormNodeType::Field { value: field_value } =
                &mut form.get_mut(child_id).node_type
            {
                if *field_value != next {
                    *field_value = next;
                    changes += 1;
                }
            }
        }
        return changes;
    }

    if let FormNodeType::Field { value: field_value } = &mut form.get_mut(node_id).node_type {
        if *field_value != value {
            *field_value = value;
            return 1;
        }
    }

    0
}

fn set_presence(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
    set_presence_inner(form, node_id, value).0
}

/// Returns `(change_count, Option<(old_str, new_str)>)` for E-4 observability.
fn set_presence_inner(
    form: &mut FormTree,
    node_id: FormNodeId,
    value: ScriptValue,
) -> (usize, Option<(&'static str, &'static str)>) {
    let value = match value {
        ScriptValue::Null => return (0, None),
        ScriptValue::String(value) => value,
    };
    let normalized = value.trim().to_ascii_lowercase();
    let new_presence = match normalized.as_str() {
        "visible" | "open" => Presence::Visible,
        "hidden" => Presence::Hidden,
        "invisible" => Presence::Invisible,
        "inactive" => Presence::Inactive,
        _ => return (0, None),
    };

    let meta = form.meta_mut(node_id);
    let old_presence = meta.presence;
    if old_presence == new_presence {
        return (0, None);
    }
    meta.presence = new_presence;

    fn pres_str(p: Presence) -> &'static str {
        match p {
            Presence::Visible => "visible",
            Presence::Hidden => "hidden",
            Presence::Invisible => "invisible",
            Presence::Inactive => "inactive",
        }
    }
    let mutation = (pres_str(old_presence), pres_str(new_presence));
    if runtime_diag_enabled() {
        let node_name = form.get(node_id).name.clone();
        push_presence_mutation(node_id.0, &node_name, mutation.0, mutation.1);
    }
    (1, Some(mutation))
}

fn normalize_number(number: f64) -> String {
    if number.fract().abs() < f64::EPSILON {
        (number as i64).to_string()
    } else {
        number.to_string()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ScriptValue {
    Null,
    String(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use xfa_layout_engine::form::{
        FieldKind, FormNode, FormNodeMeta, FormNodeStyle, GroupKind, Occur,
    };
    use xfa_layout_engine::text::FontMetrics;
    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};

    fn add_node(tree: &mut FormTree, name: &str, node_type: FormNodeType) -> FormNodeId {
        tree.add_node(FormNode {
            name: name.to_string(),
            node_type,
            box_model: BoxModel::default(),
            layout: LayoutStrategy::TopToBottom,
            children: Vec::new(),
            occur: Occur::once(),
            font: FontMetrics::default(),
            calculate: None,
            validate: None,
            column_widths: Vec::new(),
            col_span: 1,
        })
    }

    fn empty_meta() -> FormNodeMeta {
        FormNodeMeta {
            field_kind: FieldKind::Text,
            group_kind: GroupKind::None,
            style: FormNodeStyle::default(),
            ..Default::default()
        }
    }

    fn formcalc_script(script: &str, activity: &str) -> EventScript {
        EventScript::formcalc(script, Some(activity))
    }

    fn javascript_script(script: &str, activity: &str) -> EventScript {
        EventScript::javascript(script, Some(activity))
    }

    fn other_script(script: &str, activity: &str) -> EventScript {
        EventScript::new(
            script.to_string(),
            ScriptLanguage::Other,
            Some(activity.to_string()),
            None,
            None,
        )
    }

    fn script_policy_fixture(include_js: bool) -> (FormTree, FormNodeId, FormNodeId) {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let js_hook = add_node(
            &mut tree,
            "JsHook",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        let runner = add_node(
            &mut tree,
            "Runner",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        let target = add_node(
            &mut tree,
            "Target",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![js_hook, runner, target];
        if include_js {
            tree.meta_mut(js_hook).event_scripts = vec![javascript_script(
                "xfa.host.messageBox('skip');",
                "initialize",
            )];
        }
        tree.meta_mut(runner).event_scripts =
            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];

        (tree, root, target)
    }

    fn other_language_policy_fixture() -> (FormTree, FormNodeId, FormNodeId) {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let other = add_node(&mut tree, "OtherHook", FormNodeType::Subform);
        let runner = add_node(&mut tree, "Runner", FormNodeType::Subform);
        let target = add_node(
            &mut tree,
            "Target",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![other, runner, target];
        tree.meta_mut(other).event_scripts = vec![other_script("MsgBox \"skip\"", "initialize")];
        tree.meta_mut(runner).event_scripts =
            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];

        (tree, root, target)
    }

    fn field_value(tree: &FormTree, node_id: FormNodeId) -> &str {
        match &tree.get(node_id).node_type {
            FormNodeType::Field { value } => value,
            _ => panic!("expected field"),
        }
    }

    #[test]
    fn change_event_toggles_relative_hidden_subform() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
        let group = add_node(&mut tree, "Choice", FormNodeType::Subform);
        let option1 = add_node(
            &mut tree,
            "Option1",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );
        let option2 = add_node(
            &mut tree,
            "Option2",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        let details = add_node(&mut tree, "Details", FormNodeType::Subform);

        tree.get_mut(root).children = vec![section];
        tree.get_mut(section).children = vec![group, details];
        tree.get_mut(group).children = vec![option1, option2];

        tree.meta_mut(group).group_kind = GroupKind::ExclusiveChoice;
        tree.meta_mut(group).event_scripts = vec![formcalc_script(
            r#"
Details.presence = "hidden"
if (this.rawValue == 1) then
  Details.presence = "visible"
endif
"#,
            "initialize",
        )];
        tree.meta_mut(option1).item_value = Some("1".into());
        tree.meta_mut(option2).item_value = Some("2".into());
        tree.meta_mut(details).presence = Presence::Hidden;

        apply_dynamic_scripts(&mut tree, root).unwrap();

        assert_eq!(tree.meta(details).presence, Presence::Visible);
    }

    #[test]
    fn calculate_script_on_hidden_block_uses_sibling_values() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
        let option1 = add_node(
            &mut tree,
            "Opt1",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );
        let option2 = add_node(
            &mut tree,
            "Opt2",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        let details = add_node(&mut tree, "Details", FormNodeType::Subform);

        tree.get_mut(root).children = vec![section];
        tree.get_mut(section).children = vec![option1, option2, details];
        tree.meta_mut(details).presence = Presence::Hidden;
        tree.meta_mut(details).event_scripts = vec![formcalc_script(
            r#"
this.presence = "hidden"
if ((Opt1.rawValue == 1) or (Opt2.rawValue == 1)) then
  this.presence = "visible"
endif
"#,
            "calculate",
        )];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        assert_eq!(tree.meta(details).presence, Presence::Visible);
    }

    #[test]
    fn multi_pass_scripts_propagate_raw_values() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
        let controller = add_node(
            &mut tree,
            "Controller",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );
        let target = add_node(
            &mut tree,
            "Target",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        let details = add_node(&mut tree, "Details", FormNodeType::Subform);

        tree.get_mut(root).children = vec![section];
        tree.get_mut(section).children = vec![controller, target, details];

        tree.meta_mut(controller).event_scripts = vec![formcalc_script(
            r#"
if (this.rawValue == 1) then
  Target.rawValue = 1
endif
"#,
            "calculate",
        )];
        tree.meta_mut(details).presence = Presence::Hidden;
        tree.meta_mut(details).event_scripts = vec![formcalc_script(
            r#"
this.presence = "hidden"
if (Target.rawValue == 1) then
  this.presence = "visible"
endif
"#,
            "calculate",
        )];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        if let FormNodeType::Field { value } = &tree.get(target).node_type {
            assert_eq!(value, "1");
        } else {
            panic!("expected field");
        }
        assert_eq!(tree.meta(details).presence, Presence::Visible);
    }

    // ─── #1097: FormCalc SOM bridge hardening ────────────────────────────────

    /// SOM path `form1.#subform[0].field1.rawValue` resolves correctly on a
    /// simple form tree.
    #[test]
    fn som_path_resolves_on_simple_form_tree() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
        let subform = add_node(&mut tree, "subform1", FormNodeType::Subform);
        let field1 = add_node(
            &mut tree,
            "field1",
            FormNodeType::Field {
                value: "hello".to_string(),
            },
        );

        tree.get_mut(root).children = vec![form1];
        tree.get_mut(form1).children = vec![subform];
        tree.get_mut(subform).children = vec![field1];

        // Use a calculate script to read the value via absolute SOM path
        tree.meta_mut(root).event_scripts = vec![formcalc_script(
            "form1.subform1.field1.rawValue",
            "calculate",
        )];

        let parents = super::build_parent_map(&tree, root);
        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
        let target = resolver.resolve_target("form1.subform1.field1.rawValue");
        assert!(target.is_some(), "SOM path must resolve to a node");
        let target = target.unwrap();
        let val = super::read_formcalc_value(&tree, root, &parents, target);
        match val {
            formcalc_interpreter::value::Value::String(s) => assert_eq!(s, "hello"),
            formcalc_interpreter::value::Value::Number(n) => {
                // number coercion: not expected here
                panic!("expected string, got number {n}")
            }
            _ => panic!("expected string value"),
        }
    }

    /// An invalid SOM path returns `None` (descriptive non-panic failure).
    #[test]
    fn invalid_som_path_returns_none_not_panic() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let parents = super::build_parent_map(&tree, root);
        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);

        // This should not panic — it should return None
        let result = resolver.resolve_target("nonexistent.deep.path.rawValue");
        assert!(
            result.is_none(),
            "invalid SOM path must return None, not panic"
        );
    }

    #[test]
    fn best_effort_skips_javascript_and_runs_formcalc() {
        let (mut tree, root, target) = script_policy_fixture(true);

        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();

        assert_eq!(field_value(&tree, target), "ran");
        assert!(outcome.js_present);
        assert_eq!(outcome.js_skipped, 1);
        assert_eq!(outcome.other_skipped, 0);
        assert_eq!(outcome.formcalc_run, 1);
        assert_eq!(outcome.formcalc_errors, 0);
        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
    }

    #[test]
    fn strict_mode_preserves_javascript_reject() {
        let (mut tree, root, target) = script_policy_fixture(true);

        let err =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();

        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
        assert_eq!(field_value(&tree, target), "");
    }

    #[test]
    fn formcalc_only_reports_exact_quality() {
        let (mut tree, root, target) = script_policy_fixture(false);

        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();

        assert_eq!(field_value(&tree, target), "ran");
        assert!(!outcome.js_present);
        assert_eq!(outcome.js_skipped, 0);
        assert_eq!(outcome.other_skipped, 0);
        assert_eq!(outcome.formcalc_run, 1);
        assert_eq!(outcome.formcalc_errors, 0);
        assert_eq!(outcome.output_quality, OutputQuality::Exact);
    }

    #[test]
    fn other_language_scripts_skip_in_best_effort_and_reject_in_strict() {
        let (mut tree, root, target) = other_language_policy_fixture();
        let (mut strict_tree, strict_root, _) = other_language_policy_fixture();

        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
        assert_eq!(field_value(&tree, target), "ran");
        assert_eq!(outcome.js_skipped, 0);
        assert_eq!(outcome.other_skipped, 1);
        assert_eq!(outcome.formcalc_run, 1);
        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);

        let err =
            apply_dynamic_scripts_with_mode(&mut strict_tree, strict_root, JsExecutionMode::Strict)
                .unwrap_err();
        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
    }

    #[test]
    fn javascript_direct_executor_call_is_still_denied() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let trigger = add_node(
            &mut tree,
            "Trigger",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        tree.get_mut(root).children = vec![trigger];

        let parents = build_parent_map(&tree, root);
        let script = javascript_script("xfa.host.messageBox('deny');", "initialize");
        let err = execute_event_script(
            &mut tree,
            root,
            &parents,
            trigger,
            &script,
            ScriptPhase::Initialize,
        )
        .unwrap_err();

        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
    }

    #[test]
    fn javascript_resolve_node_call_is_explicitly_denied() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let form = add_node(&mut tree, "formulier1", FormNodeType::Subform);
        let admin = add_node(&mut tree, "ADMIN", FormNodeType::Subform);
        let lock = add_node(
            &mut tree,
            "LockForm_AD",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );
        let reset = add_node(
            &mut tree,
            "Reset",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );

        tree.get_mut(root).children = vec![form];
        tree.get_mut(form).children = vec![admin, reset];
        tree.get_mut(admin).children = vec![lock];
        tree.meta_mut(reset).event_scripts = vec![javascript_script(
            r#"xfa.resolveNode("formulier1.ADMIN.LockForm_AD").rawValue = 0;"#,
            "initialize",
        )];

        let err =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));

        if let FormNodeType::Field { value } = &tree.get(lock).node_type {
            assert_eq!(value, "1");
        } else {
            panic!("expected field");
        }
    }

    #[test]
    fn javascript_utils_hide_if_empty_is_explicitly_denied() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let empty = add_node(
            &mut tree,
            "EmptyField",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        tree.get_mut(root).children = vec![empty];
        tree.meta_mut(empty).event_scripts =
            vec![javascript_script("Utils.hideIfEmpty(this);", "initialize")];

        let err =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));

        assert!(!tree.meta(empty).presence.is_not_visible());
    }

    #[test]
    fn malformed_javascript_payload_is_explicitly_denied_without_panic() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let container = add_node(&mut tree, "Container", FormNodeType::Subform);
        let empty = add_node(
            &mut tree,
            "EmptyField",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![container];
        tree.get_mut(container).children = vec![empty];
        tree.meta_mut(empty).event_scripts = vec![javascript_script(
            "\0}{{not.valid.javascript(",
            "initialize",
        )];

        let err =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));

        assert!(!tree.meta(container).presence.is_not_visible());
    }

    #[test]
    fn default_meta_helper_is_constructible() {
        let meta = empty_meta();
        assert_eq!(meta.group_kind, GroupKind::None);
    }

    #[test]
    fn calculate_event_applies_formcalc_return_value() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let total = add_node(
            &mut tree,
            "Total",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![total];
        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        match &tree.get(total).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "42"),
            _ => panic!("expected field"),
        }
    }

    #[test]
    fn calculate_event_resolves_bare_field_names_as_raw_values() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
        let number1 = add_node(
            &mut tree,
            "Number1",
            FormNodeType::Field {
                value: "40".to_string(),
            },
        );
        let number2 = add_node(
            &mut tree,
            "Number2",
            FormNodeType::Field {
                value: "2".to_string(),
            },
        );
        let total = add_node(
            &mut tree,
            "Total",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![section];
        tree.get_mut(section).children = vec![number1, number2, total];
        tree.meta_mut(total).event_scripts =
            vec![formcalc_script("Number1 + Number2", "calculate")];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        match &tree.get(total).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "42"),
            _ => panic!("expected field"),
        }
    }

    #[test]
    fn click_events_are_skipped_during_flatten() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let trigger = add_node(
            &mut tree,
            "Trigger",
            FormNodeType::Field {
                value: "1".to_string(),
            },
        );
        let details = add_node(&mut tree, "Details", FormNodeType::Subform);

        tree.get_mut(root).children = vec![trigger, details];
        tree.meta_mut(details).presence = Presence::Hidden;
        tree.meta_mut(trigger).event_scripts = vec![formcalc_script(
            r#"
Details.presence = "visible"
"#,
            "click",
        )];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        assert_eq!(tree.meta(details).presence, Presence::Hidden);
    }

    #[test]
    fn rollback_when_scripts_mostly_error() {
        // Set up a form with fields that have values.  Attach scripts that
        // will fail to parse so that errors > successes.  After
        // apply_dynamic_scripts the field values must be unchanged.
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let field_a = add_node(
            &mut tree,
            "FieldA",
            FormNodeType::Field {
                value: "hello".to_string(),
            },
        );
        let field_b = add_node(
            &mut tree,
            "FieldB",
            FormNodeType::Field {
                value: "world".to_string(),
            },
        );

        tree.get_mut(root).children = vec![field_a, field_b];

        // Two scripts that fail parsing (invalid FormCalc), zero successes.
        tree.meta_mut(field_a).event_scripts = vec![formcalc_script("@@INVALID@@", "initialize")];
        tree.meta_mut(field_b).event_scripts =
            vec![formcalc_script("@@ALSO_BROKEN@@", "initialize")];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        // Fields should retain their original values (rollback).
        match &tree.get(field_a).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "hello"),
            _ => panic!("expected field"),
        }
        match &tree.get(field_b).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "world"),
            _ => panic!("expected field"),
        }
    }

    #[test]
    fn rollback_when_populated_fields_go_empty() {
        // Calculate scripts returning Null clear field values. When >50%
        // of populated fields go empty, the rollback heuristic fires.
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let field_a = add_node(
            &mut tree,
            "FieldA",
            FormNodeType::Field {
                value: "keep".to_string(),
            },
        );
        let field_b = add_node(
            &mut tree,
            "FieldB",
            FormNodeType::Field {
                value: "also_keep".to_string(),
            },
        );

        tree.get_mut(root).children = vec![field_a, field_b];

        // Calculate scripts whose return value (Null) is written to the field,
        // blanking it.  The expression `Null()` is not a real FormCalc builtin,
        // but `0` would set the field to "0" (not empty).  Instead we use the
        // snapshot/rollback logic directly.
        // We test the heuristic by manually setting up the condition.
        let snapshot = super::snapshot_form(&tree);

        // Simulate scripts clearing both fields.
        if let FormNodeType::Field { value } = &mut tree.get_mut(field_a).node_type {
            *value = String::new();
        }
        if let FormNodeType::Field { value } = &mut tree.get_mut(field_b).node_type {
            *value = String::new();
        }

        assert!(super::should_rollback(&tree, &snapshot, 0, 2));

        super::restore_snapshot(&mut tree, &snapshot);

        match &tree.get(field_a).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "keep"),
            _ => panic!("expected field"),
        }
        match &tree.get(field_b).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "also_keep"),
            _ => panic!("expected field"),
        }
    }

    #[test]
    fn no_rollback_when_scripts_succeed() {
        // A single working script with no errors → no rollback, change persists.
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let total = add_node(
            &mut tree,
            "Total",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![total];
        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];

        apply_dynamic_scripts(&mut tree, root).unwrap();

        match &tree.get(total).node_type {
            FormNodeType::Field { value } => assert_eq!(value, "42"),
            _ => panic!("expected field"),
        }
    }

    /// QF1-C regression: `formcalc_errors` counter MUST tick when a FormCalc
    /// script invokes an unknown function. This is the canonical signal the
    /// QF1-C residual scan (`scripts/xfa_formcalc_residual_scan.py`) aggregates
    /// off the `XFA script metadata:` stderr line.
    #[test]
    fn formcalc_unknown_function_increments_error_counter() {
        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let total = add_node(
            &mut tree,
            "Total",
            FormNodeType::Field {
                value: String::new(),
            },
        );

        tree.get_mut(root).children = vec![total];
        // `definitelyNotAFormCalcBuiltin(1)` triggers `FormCalcError::UnknownFunction`
        // in `crates/formcalc-interpreter/src/interpreter.rs`.
        tree.meta_mut(total).event_scripts = vec![formcalc_script(
            "definitelyNotAFormCalcBuiltin(1)",
            "calculate",
        )];

        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
        assert_eq!(outcome.formcalc_run, 1, "the script must be attempted");
        assert_eq!(
            outcome.formcalc_errors, 1,
            "unknown-function failure must increment formcalc_errors"
        );
    }

    // ─── Epic A enrichment tests ──────────────────────────────────────────────
    //
    // Environment variable writes are not `std::sync::atomic` — we serialise all
    // three tests with a process-wide `Mutex` so they cannot race on the env.
    static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();

    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        ENV_LOCK
            .get_or_init(|| std::sync::Mutex::new(()))
            .lock()
            .unwrap_or_else(|e| e.into_inner())
    }

    /// E-1: `script_lifecycle` is populated when `XFA_FLATTEN_TRACE=1`.
    ///
    /// Builds a FormTree with a single JavaScript `initialize` event on a field.
    /// In `BestEffortStatic` mode the script is skipped but still recorded in
    /// the lifecycle vec with outcome `"skipped_mode"`.
    #[test]
    fn script_lifecycle_populated_when_trace_enabled() {
        let _guard = env_lock();
        std::env::set_var("XFA_FLATTEN_TRACE", "1");

        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let field = add_node(
            &mut tree,
            "TraceField",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        tree.get_mut(root).children = vec![field];
        tree.meta_mut(field).event_scripts = vec![javascript_script(
            "xfa.host.messageBox('trace');",
            "initialize",
        )];

        let outcome =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
                .unwrap();

        std::env::remove_var("XFA_FLATTEN_TRACE");

        assert!(
            !outcome.script_lifecycle.is_empty(),
            "script_lifecycle must have at least one entry when XFA_FLATTEN_TRACE=1"
        );
        let entry = &outcome.script_lifecycle[0];
        assert_eq!(entry.node_name, "TraceField");
        assert_eq!(entry.activity, "initialize");
        assert_eq!(entry.lang, "javascript");
        // BestEffortStatic skips JS entirely — outcome must reflect that.
        assert_eq!(
            entry.outcome, "skipped_mode",
            "BestEffortStatic JS must appear as skipped_mode in lifecycle"
        );
    }

    /// E-6: `skipped_activities` tallies correctly for a `click` script.
    ///
    /// A JavaScript script with `activity="click"` cannot be executed during
    /// flatten (not in the sandbox allowlist and skipped by BestEffortStatic).
    /// When `XFA_FLATTEN_TRACE=1` the `skipped_activities.click` counter must
    /// increment by exactly 1.
    #[test]
    fn skipped_activities_tallies_click_correctly() {
        let _guard = env_lock();
        std::env::set_var("XFA_FLATTEN_TRACE", "1");

        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        let btn = add_node(
            &mut tree,
            "ClickBtn",
            FormNodeType::Field {
                value: String::new(),
            },
        );
        tree.get_mut(root).children = vec![btn];
        // `click` activity — never executed during flatten regardless of mode.
        tree.meta_mut(btn).event_scripts = vec![javascript_script(
            "xfa.host.messageBox('clicked');",
            "click",
        )];

        let outcome =
            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
                .unwrap();

        std::env::remove_var("XFA_FLATTEN_TRACE");

        assert_eq!(
            outcome.skipped_activities.click, 1,
            "exactly one click-activity JS script must be tallied in skipped_activities.click"
        );
        // Other buckets must stay at zero.
        assert_eq!(outcome.skipped_activities.initialize, 0);
        assert_eq!(outcome.skipped_activities.calculate, 0);
        assert_eq!(outcome.skipped_activities.other, 0);
    }

    /// E-5: `form_dom_match_failures` increments when `apply_form_dom_presence`
    /// suppresses a named subform absent from the form DOM.
    ///
    /// The FormTree contains a named `Subform` child ("Ghost") that does NOT
    /// appear in the form XML packet.  The form XML contains a sibling child
    /// ("Present") so that the "has_subform_children" guard inside
    /// `apply_form_dom_presence` fires and suppression logic runs.
    #[test]
    fn form_dom_match_failures_increments_for_unmatched_subform() {
        use crate::flatten::{apply_form_dom_presence, XfaRenderingPolicy};

        let _guard = env_lock();
        // E-5 diag log is armed when XFA_FLATTEN_TRACE or XFA_RUNTIME_DIAG is set.
        std::env::set_var("XFA_FLATTEN_TRACE", "1");

        let mut tree = FormTree::new();
        let root = add_node(&mut tree, "root", FormNodeType::Root);
        // "form1" is the top-level subform that the form XML root-subform matches.
        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
        // "Present" appears in the form DOM — will be matched.
        let present = add_node(&mut tree, "Present", FormNodeType::Subform);
        // "Ghost" is NOT in the form DOM — will be suppressed.
        let ghost = add_node(&mut tree, "Ghost", FormNodeType::Subform);

        tree.get_mut(root).children = vec![form1];
        tree.get_mut(form1).children = vec![present, ghost];

        // Minimal form XML: root subform "form1" contains one child "Present"
        // but no "Ghost" child.  Because "Present" is listed, the
        // `has_subform_children` guard fires and "Ghost" is suppressed.
        let form_xml = r#"<form>
  <subform name="form1">
    <subform name="Present"/>
  </subform>
</form>"#;

        let (_admitted, match_failures, match_log) = apply_form_dom_presence(
            &mut tree,
            root,
            form_xml,
            XfaRenderingPolicy::SavedStateFaithful,
            false,
        );

        std::env::remove_var("XFA_FLATTEN_TRACE");

        assert_eq!(
            match_failures, 1,
            "exactly one unmatched named subform (Ghost) must be counted"
        );
        assert_eq!(match_log.len(), 1, "match_log must contain the Ghost entry");
        assert_eq!(match_log[0].template_node_name, "Ghost");
        assert_eq!(match_log[0].reason, "formdom_unmatched_suppressed");
        // Verify presence was actually suppressed on the FormTree node.
        assert!(
            tree.meta(ghost).presence.is_not_visible(),
            "Ghost subform must have been hidden by apply_form_dom_presence"
        );
    }
}
