use std::collections::BTreeMap;

use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
use picloud_shared::{
    AppId, ExecutionId, KvEventOp, RequestId, ScriptId, ScriptSandbox, Services, TriggerEvent,
};
use serde_json::json;

fn req(body: serde_json::Value) -> ExecRequest {
    let execution_id = ExecutionId::new();
    ExecRequest {
        execution_id,
        request_id: RequestId::new(),
        script_id: ScriptId::new(),
        script_name: "test".into(),
        invocation_type: InvocationType::Http,
        path: "/test".into(),
        method: String::new(),
        headers: BTreeMap::new(),
        body,
        params: BTreeMap::new(),
        query: BTreeMap::new(),
        rest: String::new(),
        sandbox_overrides: ScriptSandbox::default(),
        app_id: AppId::new(),
        script_owner: None,
        principal: None,
        trigger_depth: 0,
        root_execution_id: execution_id,
        is_dead_letter_handler: false,
        event: None,
    }
}

fn engine() -> Engine {
    Engine::new(Limits::default(), Services::default())
}

#[test]
fn validate_accepts_well_formed_script() {
    engine()
        .validate("let x = 1; #{ statusCode: 200, body: x }")
        .expect("valid script should validate");
}

#[test]
fn validate_rejects_syntax_errors() {
    let err = engine()
        .validate("this is not rhai @@@")
        .expect_err("invalid script should not validate");
    assert!(matches!(err, ExecError::Parse(_)));
}

#[test]
fn debug_and_print_symbols_are_disabled() {
    // Audit 2026-06-11 (F-SE-H-03) — neither `print` nor `debug` may
    // reach the host's stdout/stderr; both are disabled at the symbol
    // level, so a script that calls them fails to parse/validate.
    for src in [r#"print("x")"#, r#"debug("x")"#] {
        let err = engine()
            .validate(src)
            .expect_err("disabled output symbol should be rejected");
        assert!(
            matches!(err, ExecError::Parse(_)),
            "{src} should be a parse-time rejection, got {err:?}"
        );
    }
}

#[test]
fn returns_unwrapped_value_as_200_body() {
    let resp = engine()
        .execute("42", req(json!(null)))
        .expect("should execute");
    assert_eq!(resp.status_code, 200);
    assert_eq!(resp.body, json!(42));
    assert!(resp.headers.is_empty());
}

#[test]
fn returns_structured_response_when_status_code_present() {
    let src = r#"
        #{ statusCode: 201,
           headers: #{ "x-test": "hello" },
           body: #{ ok: true, msg: "created" } }
    "#;
    let resp = engine().execute(src, req(json!(null))).unwrap();
    assert_eq!(resp.status_code, 201);
    assert_eq!(
        resp.headers.get("x-test").map(String::as_str),
        Some("hello")
    );
    assert_eq!(resp.body, json!({ "ok": true, "msg": "created" }));
}

#[test]
fn ctx_exposes_request_data() {
    let src = r"
        #{ statusCode: 200,
           body: #{
             path: ctx.request.path,
             name: ctx.script_name,
             amount: ctx.request.body.amount
           } }
    ";
    let r = ExecRequest {
        path: "/payments".into(),
        method: String::new(),
        body: json!({ "amount": 1234 }),
        script_name: "payments".into(),
        ..req(json!(null))
    };
    let resp = engine().execute(src, r).unwrap();
    assert_eq!(
        resp.body,
        json!({ "path": "/payments", "name": "payments", "amount": 1234 })
    );
}

#[test]
fn ctx_exposes_request_method() {
    // A single script can branch on the verb instead of needing one
    // script per method (E2E report F4).
    let src = r"#{ statusCode: 200, body: #{ method: ctx.request.method } }";
    let r = ExecRequest {
        method: "PATCH".into(),
        ..req(json!(null))
    };
    let resp = engine().execute(src, r).unwrap();
    assert_eq!(resp.body, json!({ "method": "PATCH" }));
}

#[test]
fn captures_log_calls() {
    let src = r#"
        log::info("starting");
        log::warn("watch out", #{ count: 3 });
        log::error("oops");
        log::trace("deep diagnostic");
        42
    "#;
    let resp = engine().execute(src, req(json!(null))).unwrap();
    assert_eq!(resp.logs.len(), 4);

    let levels: Vec<_> = resp.logs.iter().map(|l| l.level).collect();
    assert_eq!(
        levels,
        vec![
            LogLevel::Info,
            LogLevel::Warn,
            LogLevel::Error,
            LogLevel::Trace
        ]
    );
    assert_eq!(resp.logs[0].message, "starting");
    assert_eq!(resp.logs[1].data, Some(json!({ "count": 3 })));
}

#[test]
fn enforces_operation_budget() {
    let limits = Limits {
        max_operations: 1_000,
        ..Limits::default()
    };
    let engine = Engine::new(limits, Services::default());
    // 10_000 iterations vastly exceeds 1_000 ops.
    let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
    let err = engine
        .execute(src, req(json!(null)))
        .expect_err("should exceed budget");
    assert!(matches!(err, ExecError::OperationBudgetExceeded));
}

#[test]
fn per_request_sandbox_override_tightens_budget() {
    // Engine default is 1M ops — the script below would finish.
    // We override down to 500 ops on this single request; should fail.
    let engine = engine();
    let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
    let r = ExecRequest {
        sandbox_overrides: ScriptSandbox {
            max_operations: Some(500),
            ..ScriptSandbox::default()
        },
        ..req(json!(null))
    };
    let err = engine.execute(src, r).expect_err("override should tighten");
    assert!(matches!(err, ExecError::OperationBudgetExceeded));
}

#[test]
fn override_only_replaces_specified_field() {
    // Tight string size, default everything else. Strings > 32 chars
    // should fail; loops up to default 1M ops should still pass.
    let engine = engine();
    let small_string_ok = r#"let s = "hello"; #{ statusCode: 200, body: s }"#;
    let r1 = ExecRequest {
        sandbox_overrides: ScriptSandbox {
            max_string_size: Some(32),
            ..ScriptSandbox::default()
        },
        ..req(json!(null))
    };
    let resp = engine.execute(small_string_ok, r1).unwrap();
    assert_eq!(resp.body, json!("hello"));
}

#[test]
fn deadline_terminates_a_runaway_loop() {
    // Audit 2026-06-11 H-C1 closure. With a generous op budget the
    // previous engine ran a `loop {}` body until `max_operations`
    // self-exhausted (potentially seconds on Pi-class hardware) and the
    // outer `tokio::time::timeout` only dropped the awaiting future —
    // not the OS thread. With `on_progress` consulting the deadline,
    // a 100 ms deadline aborts within a few hundred ms even when the
    // op budget would allow far more work.
    let limits = Limits {
        max_operations: u64::MAX,
        ..Limits::default()
    };
    let engine = Engine::new(limits, Services::default());
    let src = r"let n = 0; loop { n += 1; }";
    let deadline = Some(std::time::Instant::now() + std::time::Duration::from_millis(100));
    let started = std::time::Instant::now();
    let err = engine
        .execute_with_deadline(src, req(json!(null)), deadline)
        .expect_err("runaway loop must terminate");
    let elapsed = started.elapsed();
    assert!(
        elapsed < std::time::Duration::from_secs(2),
        "deadline should fire within ~hundreds of ms, took {elapsed:?}"
    );
    // ErrorTerminated maps to ExecError::Runtime via map_eval_error.
    assert!(
        matches!(err, ExecError::Runtime(_)),
        "expected Runtime, got {err:?}"
    );
}

#[test]
fn no_deadline_set_does_not_abort() {
    // Smoke: a deadline-less execute is unchanged from the pre-audit
    // behavior — the per-op budget is what stops things.
    let limits = Limits {
        max_operations: 100,
        ..Limits::default()
    };
    let engine = Engine::new(limits, Services::default());
    let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
    let err = engine
        .execute_with_deadline(src, req(json!(null)), None)
        .expect_err("budget should still bite");
    assert!(matches!(err, ExecError::OperationBudgetExceeded));
}

#[test]
fn runtime_error_is_mapped_to_runtime_variant() {
    let err = engine()
        .execute("1 / 0", req(json!(null)))
        .expect_err("division by zero should error");
    assert!(matches!(err, ExecError::Runtime(_)));
}

#[test]
fn module_import_is_blocked() {
    let err = engine()
        .execute(r#"import "evil" as e; 1"#, req(json!(null)))
        .expect_err("imports should be blocked");
    // Module-not-found is reported as a runtime error via DummyModuleResolver.
    assert!(matches!(err, ExecError::Runtime(_) | ExecError::Parse(_)));
}

#[test]
fn ctx_exposes_params_query_rest() {
    let engine = engine();
    let mut r = req(json!(null));
    r.params.insert("name".into(), "alice".into());
    r.params.insert("post".into(), "42".into());
    r.query.insert("tab".into(), "details".into());
    r.rest = "extra/path".into();
    let src = r"
        #{ statusCode: 200, body: #{
            name: ctx.request.params.name,
            post: ctx.request.params.post,
            tab:  ctx.request.query.tab,
            rest: ctx.request.rest
        } }
    ";
    let resp = engine.execute(src, r).unwrap();
    assert_eq!(
        resp.body,
        json!({ "name": "alice", "post": "42", "tab": "details", "rest": "extra/path" })
    );
}

#[test]
fn ctx_exposes_sdk_version() {
    let resp = engine()
        .execute("ctx.sdk_version", req(json!(null)))
        .unwrap();
    // Whatever it is, it must look like "MAJOR.MINOR" — that's the
    // contract scripts feature-detect against.
    let v = resp.body.as_str().expect("sdk_version is a string");
    let parts: Vec<&str> = v.split('.').collect();
    assert_eq!(parts.len(), 2, "expected major.minor, got {v:?}");
    assert!(parts[0].parse::<u32>().is_ok(), "major not numeric: {v:?}");
    assert!(parts[1].parse::<u32>().is_ok(), "minor not numeric: {v:?}");
}

#[test]
fn body_passes_through_nested_json_round_trip() {
    let src = "#{ statusCode: 200, body: ctx.request.body }";
    let body = json!({
        "deep": {
            "list": [1, "two", 3.5, null, true, { "k": "v" }],
            "count": 6
        }
    });
    let resp = engine().execute(src, req(body.clone())).unwrap();
    assert_eq!(resp.body, body);
}

#[test]
fn ctx_event_absent_for_direct_invocations() {
    // Scripts not fired through the triggers framework see no
    // `ctx.event` key — they can use `"event" in ctx` to detect.
    let src = r#"
        if "event" in ctx { #{ statusCode: 500, body: "should be absent" } }
        else              { "absent" }
    "#;
    let resp = engine().execute(src, req(json!(null))).unwrap();
    assert_eq!(resp.body, json!("absent"));
}

#[test]
fn ctx_event_kv_shape_matches_design_notes() {
    // Build an ExecRequest mimicking what the dispatcher hands a
    // KV-triggered handler — `event = Some(TriggerEvent::Kv { … })`.
    let mut r = req(json!(null));
    r.event = Some(TriggerEvent::Kv {
        op: KvEventOp::Insert,
        collection: "widgets".into(),
        key: "k1".into(),
        value: Some(json!({ "n": 1 })),
    });
    let src = r"
        #{
            source: ctx.event.source,
            op: ctx.event.op,
            collection: ctx.event.kv.collection,
            key: ctx.event.kv.key,
            value: ctx.event.kv.value
        }
    ";
    let resp = engine().execute(src, r).unwrap();
    assert_eq!(
        resp.body,
        json!({
            "source": "kv",
            "op": "insert",
            "collection": "widgets",
            "key": "k1",
            "value": { "n": 1 }
        })
    );
}

#[test]
fn ctx_event_kv_delete_has_unit_value() {
    let mut r = req(json!(null));
    r.event = Some(TriggerEvent::Kv {
        op: KvEventOp::Delete,
        collection: "widgets".into(),
        key: "k1".into(),
        value: None,
    });
    let src = r"
        #{
            op: ctx.event.op,
            value_is_unit: ctx.event.kv.value == ()
        }
    ";
    let resp = engine().execute(src, r).unwrap();
    assert_eq!(resp.body, json!({ "op": "delete", "value_is_unit": true }));
}
