//! §9.4 Service Interceptors — the executor-side before-op hook.
//!
//! MVP: `kv::set` / `kv::delete` (private AND group-shared collections) run an
//! allow/deny interceptor first. The hook resolves the nearest interceptor for
//! `(service, op)` on the calling app's chain via the injected
//! `InterceptorService` (a cheap indexed query; `None` = un-hooked → allow, no
//! further work). The resolver returns a script already **sealed to the owner
//! that declared the marker** and fully materialized, so we run it straight
//! through the SAME `invoke()` re-entry core (`run_resolved_blocking`) — no
//! second dispatch mechanism, no re-resolution by name (which would let a
//! descendant app shadow a group's guard).
//!
//! **Fail closed.** Everything but an explicit allow denies:
//! - a registered marker whose script is missing/disabled → deny;
//! - a registered marker with no runnable engine back-reference → deny;
//! - a return value that is not `#{ allowed: true }` → deny.
//!
//! Only a total absence of any marker allows without running anything.
//!
//! **Re-entrancy.** An interceptor that itself performs the guarded op (e.g.
//! an "allow + audit-log" hook that writes KV) must not re-trigger itself. A
//! thread-local guard, set for the duration of the interceptor's synchronous
//! execution, makes any nested op the interceptor performs bypass interception.

use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::Duration;

use picloud_shared::{
    AppId, InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx,
};
use rhai::EvalAltResult;
use serde_json::{json, Value as Json};
use tokio::runtime::Handle as TokioHandle;

use crate::engine::Engine;
use crate::sandbox::Limits;
use crate::sdk::bridge::runtime_err;
use crate::sdk::invoke::run_resolved_blocking_with_timeout;

/// Default per-interceptor wall-clock timeout when a marker sets no `timeout_ms`
/// (§9.4 M5). Read once from `PICLOUD_INTERCEPTOR_TIMEOUT_MS`; a runaway guard
/// (`loop {}`) is denied once this elapses rather than hanging the write path.
static DEFAULT_TIMEOUT_MS: LazyLock<u32> = LazyLock::new(|| {
    std::env::var("PICLOUD_INTERCEPTOR_TIMEOUT_MS")
        .ok()
        .and_then(|s| s.parse::<u32>().ok())
        .filter(|&v| v > 0)
        .unwrap_or(5000)
});

/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every
/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry
/// engine back-reference, and the sandbox limits — everything `run_before`
/// (and, from M3, `run_after`) needs. A few `Arc`s plus a small `Limits` copy.
#[derive(Clone)]
pub(crate) struct InterceptorCtx {
    pub interceptors: Arc<dyn InterceptorService>,
    pub self_engine: Option<Arc<Engine>>,
    pub limits: Limits,
}

thread_local! {
    /// Re-entrancy depth: `> 0` while an interceptor script (and anything it
    /// synchronously invokes) is executing on this thread. The whole re-entry
    /// runs synchronously in the caller's `spawn_blocking` thread (same model
    /// as `invoke()`), so a thread-local is sufficient and correct.
    static IN_INTERCEPTOR: Cell<u32> = const { Cell::new(0) };

    /// Identity cycle guard (M2): the `script_id`s of the interceptors currently
    /// on the synchronous run stack. A chain that would run an interceptor whose
    /// script is already executing is a cycle — we DENY rather than recurse.
    /// Precise (keyed by script id) and independent of the binary
    /// `IN_INTERCEPTOR` counter, which suppresses interception of an
    /// interceptor's OWN nested writes but does not track identity.
    static VISITED: RefCell<Vec<ScriptId>> = const { RefCell::new(Vec::new()) };

    /// §9.4 M6: per-execution resolve cache. Keyed by `(app_id, service, op)` —
    /// the chain a `(service, op)` resolves to is constant for one app across an
    /// execution tree, so the FIRST hooked-eligible op of that kind pays the one
    /// chain query and every later op reuses it. The dominant win is the
    /// un-hooked case: an empty chain is cached once, so N `kv::set`s in a script
    /// with no interceptors issue ONE resolve, not N. Cleared at the outermost
    /// execution boundary so a pooled thread never serves a stale (or foreign)
    /// app's cache.
    static RESOLVE_CACHE: RefCell<HashMap<(AppId, String, String), InterceptorChain>> =
        RefCell::new(HashMap::new());
    /// Scope nesting depth for the resolve cache (mirrors the emission budget):
    /// `0` when no execution is active on this thread.
    static CACHE_ACTIVE: Cell<u32> = const { Cell::new(0) };
}

/// RAII scope wrapping one `execute_ast` for the §9.4 M6 resolve cache.
/// Re-entrancy aware: the OUTERMOST scope on a thread clears the cache on entry
/// and on final exit; a nested synchronous re-entry (invoke / interceptor)
/// shares the same cache, so a whole execution tree resolves each `(service,
/// op)` at most once.
pub(crate) struct InterceptorCacheScope;

impl InterceptorCacheScope {
    pub(crate) fn enter() -> Self {
        CACHE_ACTIVE.with(|a| {
            if a.get() == 0 {
                RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
            }
            a.set(a.get() + 1);
        });
        Self
    }
}

impl Drop for InterceptorCacheScope {
    fn drop(&mut self) {
        CACHE_ACTIVE.with(|a| {
            let n = a.get().saturating_sub(1);
            a.set(n);
            if n == 0 {
                RESOLVE_CACHE.with(|c| c.borrow_mut().clear());
            }
        });
    }
}

fn currently_in_interceptor() -> bool {
    IN_INTERCEPTOR.with(|c| c.get() > 0)
}

/// RAII: increment the re-entrancy counter while an interceptor runs, decrement
/// on drop (including on the `?`/panic-unwind paths).
struct ReentryGuard;
impl ReentryGuard {
    fn enter() -> Self {
        IN_INTERCEPTOR.with(|c| c.set(c.get().saturating_add(1)));
        Self
    }
}
impl Drop for ReentryGuard {
    fn drop(&mut self) {
        IN_INTERCEPTOR.with(|c| c.set(c.get().saturating_sub(1)));
    }
}

/// RAII for the identity cycle guard: push a `script_id` on enter, pop on drop
/// (including the `?`/panic-unwind paths), so the visited set exactly tracks the
/// interceptors on the current synchronous run stack.
struct CycleGuard;
impl CycleGuard {
    fn enter(id: ScriptId) -> Self {
        VISITED.with(|v| v.borrow_mut().push(id));
        Self
    }
    fn contains(id: ScriptId) -> bool {
        VISITED.with(|v| v.borrow().contains(&id))
    }
}
impl Drop for CycleGuard {
    fn drop(&mut self) {
        VISITED.with(|v| {
            v.borrow_mut().pop();
        });
    }
}

/// The parsed outcome of ONE allowing hook: the operation passes, optionally
/// carrying a `data` transform (M4) the caller writes instead of the original.
struct HookAllow {
    /// `Some` iff the hook returned a `data` key alongside `allowed: true`.
    data: Option<Json>,
}

/// Run the before-op interceptor CHAIN for `(service, op)` if any are
/// registered. Returns `Ok(Some(value))` when a before-hook rewrote the payload
/// via a `data` transform (M4) — the caller writes THAT instead of the original
/// — or `Ok(None)` to write the original unchanged (un-hooked, or every hook
/// allowed without transforming). An `Err` denies the operation (the caller
/// must NOT perform the write); the FIRST deny short-circuits. `value` is the
/// original payload (`None` for delete, which never transforms).
#[allow(clippy::too_many_arguments)]
pub(super) fn run_before(
    ictx: &InterceptorCtx,
    cx: &Arc<SdkCallCx>,
    service: &'static str,
    op: &'static str,
    collection: &str,
    key: &str,
    value: Option<&Json>,
) -> Result<Option<Json>, Box<EvalAltResult>> {
    // Re-entrancy break: writes performed BY an interceptor (or anything it
    // invokes) are not themselves intercepted — otherwise an "allow + write"
    // hook would recurse to the depth cap and deny the original op.
    if currently_in_interceptor() {
        return Ok(None);
    }

    let chain = resolve_chain(ictx, cx, service, op)?;
    if chain.before.is_empty() {
        return Ok(None);
    }

    // Thread the (possibly rewritten) value through the chain: each before-hook
    // sees the prior hook's transform in `value`, and the write uses the final.
    let mut current: Option<Json> = value.cloned();
    let mut transformed = false;
    for entry in &chain.before {
        let payload = op_payload(service, op, collection, key, current.as_ref(), cx, None);
        let outcome = run_one_hook(ictx, cx, entry, service, op, &payload)?;
        // M4 data-transform: honored ONLY on an allowing hook (the verdict parse
        // already enforced fail-closed). Delete has no value to rewrite.
        if let Some(data) = outcome.data {
            if value.is_some() {
                enforce_transform_size(service, op, &data)?;
                current = Some(data);
                transformed = true;
            }
        }
    }

    Ok(transformed.then(|| current.unwrap_or(Json::Null)))
}

/// Run the after-op interceptor CHAIN (M3) once the write has committed.
/// `result` is the write's outcome (serialized), surfaced to the hook so it can
/// observe/audit. After-hooks CANNOT roll back — the write already happened; a
/// deny here surfaces as an operation error to the caller but the write
/// persists. Returns `Ok(())` when every after-hook allowed (or none exist), an
/// `Err` on the first deny.
#[allow(clippy::too_many_arguments)]
pub(super) fn run_after(
    ictx: &InterceptorCtx,
    cx: &Arc<SdkCallCx>,
    service: &'static str,
    op: &'static str,
    collection: &str,
    key: &str,
    value: Option<&Json>,
    result: Json,
) -> Result<(), Box<EvalAltResult>> {
    if currently_in_interceptor() {
        return Ok(());
    }
    let chain = resolve_chain(ictx, cx, service, op)?;
    if chain.after.is_empty() {
        return Ok(());
    }
    for entry in &chain.after {
        let payload = op_payload(service, op, collection, key, value, cx, Some(&result));
        // After-hooks observe/deny; a returned `data` is ignored (the write is
        // already durable, so there is nothing to rewrite for the kv slice).
        run_one_hook(ictx, cx, entry, service, op, &payload)?;
    }
    Ok(())
}

/// Resolve the before+after chain for `(service, op)` — every marker visible on
/// the calling app's chain, ordered per phase. Memoized per execution tree
/// (§9.4 M6): the FIRST call for a `(app_id, service, op)` pays the chain query;
/// later calls reuse the cached result. An `Err` (no tokio runtime, or a backend
/// failure) makes the caller fail closed rather than silently allow.
fn resolve_chain(
    ictx: &InterceptorCtx,
    cx: &Arc<SdkCallCx>,
    service: &'static str,
    op: &'static str,
) -> Result<InterceptorChain, Box<EvalAltResult>> {
    let cache_key = (cx.app_id, service.to_string(), op.to_string());
    if let Some(hit) = RESOLVE_CACHE.with(|c| c.borrow().get(&cache_key).cloned()) {
        return Ok(hit);
    }
    let handle = TokioHandle::try_current()
        .map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
    let interceptors = ictx.interceptors.clone();
    let resolve_cx = cx.clone();
    let chain = handle
        .block_on(async move { interceptors.resolve(&resolve_cx, service, op).await })
        .map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?;
    RESOLVE_CACHE.with(|c| c.borrow_mut().insert(cache_key, chain.clone()));
    Ok(chain)
}

/// Build the operation-context payload handed to a hook. `result` is `Some` only
/// for an after-hook (the write's outcome).
fn op_payload(
    service: &str,
    op: &str,
    collection: &str,
    key: &str,
    value: Option<&Json>,
    cx: &SdkCallCx,
    result: Option<&Json>,
) -> Json {
    let mut m = json!({
        "service": service,
        "action": op,
        "collection": collection,
        "key": key,
        "value": value,
        "caller_script_id": cx.script_id.to_string(),
        "caller_execution_id": cx.execution_id.to_string(),
    });
    if let (Json::Object(obj), Some(r)) = (&mut m, result) {
        obj.insert("result".to_string(), r.clone());
    }
    m
}

/// Run ONE chain entry: fail-closed on a dangling marker, an identity cycle, a
/// missing engine back-reference, or a depth-limit breach; otherwise run the
/// sealed script and parse its verdict. Returns the allow outcome (with any
/// `data` transform) or an `Err` denying the operation.
fn run_one_hook(
    ictx: &InterceptorCtx,
    cx: &Arc<SdkCallCx>,
    entry: &InterceptorEntry,
    service: &str,
    op: &str,
    payload: &Json,
) -> Result<HookAllow, Box<EvalAltResult>> {
    let resolved = match entry {
        // A guard is declared but its script is missing/disabled — fail closed.
        InterceptorEntry::Dangling(name) => {
            return Err(runtime_err(&format!(
                "{service}::{op} denied: interceptor script `{name}` is missing or disabled"
            )));
        }
        InterceptorEntry::Run(r) => r,
    };
    let script = &resolved.script;

    // Identity cycle guard: if this interceptor's script is already on the run
    // stack, running it again would recurse forever — DENY.
    if CycleGuard::contains(script.script_id) {
        return Err(runtime_err(&format!(
            "{service}::{op} denied: interceptor cycle detected: `{}`",
            script.name
        )));
    }

    // A guard is registered but the engine back-reference isn't installed (a bare
    // test engine without `set_self_weak`): we cannot run it, so DENY — a
    // resolvable guard we can't execute must not fail open. Production always
    // installs the back-ref.
    let Some(self_engine) = ictx.self_engine.as_ref() else {
        return Err(runtime_err(&format!(
            "{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
            script.name
        )));
    };

    // Depth bound (shared with invoke / trigger fan-out).
    if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
        return Err(runtime_err(&format!(
            "{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
            script.name, ictx.limits.trigger_depth_max
        )));
    }

    // Run the sealed script with the operation context as its request body, under
    // the re-entrancy guard (its own writes bypass interception), the identity
    // cycle guard (its own script id is on the stack), and a per-hook timeout
    // (§9.4 M5 — the marker's `timeout_ms` or the env default, tightened to at
    // most the caller's remaining deadline). A runaway guard is denied, not hung.
    let timeout = Duration::from_millis(u64::from(
        resolved.timeout_ms.unwrap_or(*DEFAULT_TIMEOUT_MS),
    ));
    let ret = {
        let _reentry = ReentryGuard::enter();
        let _cycle = CycleGuard::enter(script.script_id);
        run_resolved_blocking_with_timeout(
            self_engine,
            cx,
            script,
            payload.clone(),
            &format!("{service}::{op} interceptor `{}`", script.name),
            Some(timeout),
        )?
    };

    // Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A bare
    // bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other shape
    // DENIES — a control whose job is to deny must not allow by omission.
    match ret {
        Json::Object(mut m) if m.get("allowed") == Some(&Json::Bool(true)) => Ok(HookAllow {
            data: m.remove("data"),
        }),
        Json::Object(m) => {
            let reason = m
                .get("reason")
                .and_then(Json::as_str)
                .unwrap_or("denied by interceptor");
            Err(runtime_err(&format!(
                "{service}::{op} denied by interceptor `{}`: {reason}",
                script.name
            )))
        }
        _ => Err(runtime_err(&format!(
            "{service}::{op} denied by interceptor `{}`: no allow verdict returned",
            script.name
        ))),
    }
}

/// Cap a `data` transform at the same byte ceiling the SDK uses for a Rhai→JSON
/// materialization, so an interceptor can't rewrite a small write into an
/// unbounded blob before it reaches the service's own value cap.
fn enforce_transform_size(service: &str, op: &str, data: &Json) -> Result<(), Box<EvalAltResult>> {
    let len = serde_json::to_vec(data)
        .map(|v| v.len())
        .unwrap_or(usize::MAX);
    if len > crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES {
        return Err(runtime_err(&format!(
            "{service}::{op} interceptor transform too large ({len} bytes exceeds the {}-byte limit)",
            crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES
        )));
    }
    Ok(())
}
