//! Mizan SSR engine.
//!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
//! web-platform implementations, not shims (a partial polyfill is
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! per-render data crosses as a `v8::json::parse`d value passed as a function
//! argument, so a prop string has no source to break out of — code injection
//! is structurally absent, not filtered.

use std::sync::Arc;

use anyhow::{anyhow, Context, Result};
use deno_core::{v8, JsRuntime, RuntimeOptions};
use deno_web::{BlobStore, InMemoryBroadcastChannel};

/// Install the web-platform globals react-dom touches at module-init, pulled
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
/// registers these as lazy modules and installs nothing on `globalThis` until
/// asked — this is the minimal bootstrap that asks.
const INSTALL_WEB_GLOBALS: &str = r#"{
  const lazy = (s) => Deno.core.loadExtScript(s);
  const mp = lazy("ext:deno_web/13_message_port.js");
  globalThis.MessageChannel = mp.MessageChannel;
  globalThis.MessagePort = mp.MessagePort;
  const te = lazy("ext:deno_web/08_text_encoding.js");
  globalThis.TextEncoder = te.TextEncoder;
  globalThis.TextDecoder = te.TextDecoder;
}"#;

/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread).
pub struct SsrEngine {
    runtime: JsRuntime,
}

impl SsrEngine {
    /// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
    pub fn new(bundle: String) -> Result<Self> {
        let mut runtime = JsRuntime::new(RuntimeOptions {
            extensions: vec![
                deno_webidl::deno_webidl::init(),
                deno_web::deno_web::init(
                    Arc::new(BlobStore::default()),
                    None,  // maybe_location
                    false, // enable_css_parser_features
                    InMemoryBroadcastChannel::default(),
                ),
            ],
            ..Default::default()
        });
        runtime
            .execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
            .context("installing web-platform globals")?;
        runtime
            .execute_script("[mizan:bundle]", bundle)
            .context("evaluating the SSR bundle")?;
        Ok(Self { runtime })
    }

    /// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
    /// is a JSON object string; it is parsed to a V8 value and passed as an
    /// argument — never spliced into evaluated source.
    pub fn render(&mut self, props_json: &str) -> Result<String> {
        deno_core::scope!(scope, &mut self.runtime);
        let context = scope.get_current_context();
        let global = context.global(scope);

        let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
        let func_val = global
            .get(scope, key.into())
            .ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
        let func: v8::Local<v8::Function> = func_val
            .try_into()
            .map_err(|_| anyhow!("renderApp is not a function"))?;

        let props_str = v8::String::new(scope, props_json).context("intern props")?;
        let props = v8::json::parse(scope, props_str)
            .ok_or_else(|| anyhow!("props are not valid JSON"))?;

        let recv = v8::undefined(scope).into();
        let result = func
            .call(scope, recv, &[props])
            .ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
        Ok(result.to_rust_string_lossy(scope))
    }
}

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

    #[tokio::test]
    async fn renders_react_bundle_in_embedded_v8() {
        let bundle = std::fs::read_to_string(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixture/bundle.js"
        ))
        .expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");

        let mut engine = SsrEngine::new(bundle).expect("engine init");
        let html = engine.render(r#"{"name":"World"}"#).expect("render");
        assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
    }

    #[tokio::test]
    async fn props_cannot_inject_code() {
        // A prop value that would break out of a string-built `renderApp(...)`
        // call. Through the value-call path it is inert data: it reaches the
        // component as a string, never as source.
        let bundle = std::fs::read_to_string(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixture/bundle.js"
        ))
        .expect("fixture bundle");

        let mut engine = SsrEngine::new(bundle).expect("engine init");
        let html = engine
            .render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
            .expect("render");
        // The payload rendered as text; it did not execute.
        assert!(html.contains("__pwned"));
    }
}
