//! Per-execution fan-out ceiling (audit fix #2).
//!
//! `trigger_depth` bounds how DEEP a trigger/invoke chain can go, but nothing
//! bounded how WIDE a single execution could fan out: one anonymous request
//! running `for i in 0..1_000_000 { invoke_async("w", #{}) }` costs a few Rhai
//! ops per iteration plus one cheap outbox INSERT, so within the op / wall-clock
//! budget it could write ~10^5 durable rows — each dispatched as its own
//! execution — flooding the outbox/queue (a durable amplification DoS).
//!
//! This caps the number of DURABLE emissions (`invoke_async`, `publish_durable`,
//! `enqueue`, and their group-shared variants) a single execution may make.
//! The counter is a thread-local. [`EmissionBudgetScope`] is created around
//! EVERY `execute_ast` and is **re-entrancy aware**: only the OUTERMOST scope
//! on a thread resets the counter. A dispatched handler (queue/trigger/
//! `invoke_async`/cron/workflow) is a fresh task on a pooled thread, so its
//! scope is outermost → it resets and gets a full budget. A synchronous
//! `invoke()` / interceptor re-entry runs a nested `execute_ast` in the SAME
//! call stack, so its scope is inner → it does NOT reset, and the whole
//! synchronous chain shares one budget (fan-out counted across it). The
//! outermost Drop re-zeroes the counter so a pooled thread never leaks a count
//! into the next task.

use std::cell::Cell;
use std::sync::LazyLock;

use rhai::EvalAltResult;

use crate::sdk::bridge::runtime_err;

/// Default max durable emissions per execution. Generous for a legit batch
/// producer, far below the ~10^5 a runaway loop can reach within budget.
const DEFAULT_MAX_EMISSIONS: u32 = 1000;

static MAX_EMISSIONS: LazyLock<u32> = LazyLock::new(|| {
    std::env::var("PICLOUD_MAX_EMISSIONS_PER_EXECUTION")
        .ok()
        .and_then(|v| v.parse::<u32>().ok())
        .filter(|&n| n > 0)
        .unwrap_or(DEFAULT_MAX_EMISSIONS)
});

thread_local! {
    static EMISSIONS: Cell<u32> = const { Cell::new(0) };
    /// Scope nesting depth on this thread. `0` when no execution is active.
    static ACTIVE: Cell<u32> = const { Cell::new(0) };
}

/// Charge one durable emission against the current execution's budget. Returns
/// an `Err` (aborting the emit) once the ceiling is exceeded.
///
/// # Errors
/// Returns a Rhai runtime error when the per-execution emission ceiling is hit.
pub(super) fn charge_emission(service: &str) -> Result<(), Box<EvalAltResult>> {
    EMISSIONS.with(|c| {
        let next = c.get().saturating_add(1);
        if next > *MAX_EMISSIONS {
            return Err(runtime_err(&format!(
                "{service}: per-execution emission limit exceeded (max {} durable \
                 invoke_async/publish/enqueue calls per execution)",
                *MAX_EMISSIONS
            )));
        }
        c.set(next);
        Ok(())
    })
}

/// RAII scope wrapping one `execute_ast`. Re-entrancy aware: the OUTERMOST scope
/// on a thread zeroes the counter on entry and on final exit; nested scopes (a
/// synchronous re-entry in the same call stack) leave it alone, so the chain
/// shares one budget.
pub(crate) struct EmissionBudgetScope;

impl EmissionBudgetScope {
    pub(crate) fn enter() -> Self {
        ACTIVE.with(|a| {
            if a.get() == 0 {
                EMISSIONS.with(|c| c.set(0));
            }
            a.set(a.get() + 1);
        });
        Self
    }
}

impl Drop for EmissionBudgetScope {
    fn drop(&mut self) {
        ACTIVE.with(|a| {
            let n = a.get().saturating_sub(1);
            a.set(n);
            if n == 0 {
                EMISSIONS.with(|c| c.set(0));
            }
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn outermost_scope_resets_but_nested_shares_budget() {
        let outer = EmissionBudgetScope::enter();
        for _ in 0..DEFAULT_MAX_EMISSIONS {
            charge_emission("test").expect("under budget");
        }
        // A NESTED scope (synchronous re-entry) must NOT reset — the budget is
        // already exhausted and stays that way.
        {
            let _nested = EmissionBudgetScope::enter();
            assert!(
                charge_emission("test").is_err(),
                "a nested re-entry shares the exhausted budget"
            );
        }
        // Still exhausted after the nested scope drops (outer still active).
        assert!(charge_emission("test").is_err());
        drop(outer);
        // A fresh OUTERMOST scope resets.
        let _fresh = EmissionBudgetScope::enter();
        charge_emission("test").expect("reset by a fresh outermost scope");
    }
}
