//! §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;
use std::sync::Arc;

use picloud_shared::{InterceptorResolution, InterceptorService, 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;

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) };
}

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)));
    }
}

/// Run the before-op interceptor for `(service, op)` if one is registered.
/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor
/// explicitly allowed it) or an `Err` runtime error to deny it (the caller must
/// NOT then perform the write). `value` is the payload being written (`None`
/// for delete).
#[allow(clippy::too_many_arguments)]
pub(super) fn run_before(
    interceptors: &Arc<dyn InterceptorService>,
    self_engine: Option<&Arc<Engine>>,
    cx: &Arc<SdkCallCx>,
    limits: Limits,
    service: &'static str,
    op: &'static str,
    collection: &str,
    key: &str,
    value: Option<&Json>,
) -> Result<(), 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(());
    }

    let handle = TokioHandle::try_current()
        .map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;

    // (1) Resolve the nearest interceptor, sealed to its declaring owner.
    let resolution = {
        let interceptors = interceptors.clone();
        let cx = cx.clone();
        handle
            .block_on(async move { interceptors.resolve_before(&cx, service, op).await })
            .map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
    };
    let resolved = match resolution {
        // Un-hooked: the ONLY path that allows without running anything.
        InterceptorResolution::None => return Ok(()),
        // A guard is declared but its script is missing/disabled — fail closed.
        InterceptorResolution::Dangling(name) => {
            return Err(runtime_err(&format!(
                "{service}::{op} denied: interceptor script `{name}` is missing or disabled"
            )));
        }
        InterceptorResolution::Run(r) => r,
    };

    // 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) = self_engine else {
        return Err(runtime_err(&format!(
            "{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
            resolved.name
        )));
    };

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

    // (2) Run the sealed script with the operation context as its request body,
    // under the re-entrancy guard so its own writes bypass interception.
    let payload = 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(),
    });
    let ret = {
        let _guard = ReentryGuard::enter();
        run_resolved_blocking(
            self_engine,
            cx,
            &resolved,
            payload,
            &format!("{service}::{op} interceptor `{}`", resolved.name),
        )?
    };

    // 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.
    if let Json::Object(m) = &ret {
        if m.get("allowed") == Some(&Json::Bool(true)) {
            return Ok(());
        }
        let reason = m
            .get("reason")
            .and_then(Json::as_str)
            .unwrap_or("denied by interceptor");
        return Err(runtime_err(&format!(
            "{service}::{op} denied by interceptor `{}`: {reason}",
            resolved.name
        )));
    }
    Err(runtime_err(&format!(
        "{service}::{op} denied by interceptor `{}`: no allow verdict returned",
        resolved.name
    )))
}
