//! JavaScript probe definitions for cross-context identity surfaces.
//!
//! Each probe is a self-contained JavaScript expression that
//! evaluates to a JSON string matching the [`IdentitySurface`]
//! schema (or a `{"skipped":"..."}` envelope when the context is
//! unavailable).
//!
//! All scripts:
//!
//! - Use old-style `function ()` / `var` rather than arrow
//!   functions or `let`/`const` for broadest browser-engine
//!   compatibility.
//! - Wrap reads in `try/catch` so a single missing field never
//!   aborts the whole probe.
//! - Never panic; a context that cannot be probed returns
//!   `{"skipped":"<reason>"}` which [`super::ContextObservation`]
//!   decodes into a [`Skipped`][super::ContextObservation::Skipped]
//!   marker.
//!
//! ## Worker probe synchronous-busy-wait rationale
//!
//! The worker probe posts a message to a `Worker` constructed from
//! a `Blob` URL and then busy-waits on the main thread until the
//! worker posts back its identity surface (or the deadline
//! elapses). The busy-wait is bounded to a few hundred milliseconds
//! — workers always reply in microseconds on a clean browser, and
//! the timeout ensures the runner can never block indefinitely.
//!
//! ## Iframe probe srcdoc rationale
//!
//! The iframe probe creates a same-origin `<iframe>` with a
//! `srcdoc` payload. `srcdoc` iframes inherit the parent's origin
//! and provide a deterministic, network-free context for probing.

use serde::{Deserialize, Serialize};

use crate::error::{BrowserError, Result};
use crate::page::PageHandle;

use super::report::{ContextObservation, IdentitySurface};

// ─── Probed JSON shape ────────────────────────────────────────────────────────

/// Wire format for a probe result: an [`IdentitySurface`] flattened
/// with an optional `skipped` discriminator field.
///
/// The probe script returns either:
///
/// - `{"skipped": "<reason>"}` — probe could not run; the reason
///   decodes to a [`Skipped`][ContextObservation::Skipped] marker.
/// - `{ "user_agent": "...", ... }` — a (possibly partial)
///   [`IdentitySurface`] that decodes to an
///   [`Observed`][ContextObservation::Observed] marker.
///
/// The two cases are distinguished by the presence of a
/// `skipped` field. Using a wire wrapper struct (instead of an
/// `#[serde(untagged)]` enum) keeps the discriminator explicit and
/// avoids the silent-unknown-field behaviour of untagged enums.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
struct ProbeOutput {
    /// `Some(reason)` when the probe could not run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    skipped: Option<String>,
    /// Flattened identity surface. `Default::default()` when the
    /// probe returned only a `skipped` marker.
    #[serde(default, flatten)]
    surface: IdentitySurface,
}

impl ProbeOutput {
    fn into_observation(self) -> ContextObservation {
        match self.skipped {
            Some(reason) => ContextObservation::skipped(reason),
            None => ContextObservation::observed(self.surface),
        }
    }
}

// ─── JavaScript probe scripts ─────────────────────────────────────────────────

const TOP_LEVEL_PROBE: &str = concat!(
    "(function(){",
    "var r={};",
    "try{r.userAgent=String(navigator.userAgent||'');}catch(e){r.userAgent='';}",
    "try{r.platform=String(navigator.platform||'');}catch(e){r.platform='';}",
    "try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(e){r.languages='';}",
    "try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
    "try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(e){r.device_memory=null;}",
    "try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
    "try{r.screen_width=typeof screen==='object'&&screen&&typeof screen.width==='number'?screen.width:null;}catch(e){r.screen_width=null;}",
    "try{r.screen_height=typeof screen==='object'&&screen&&typeof screen.height==='number'?screen.height:null;}catch(e){r.screen_height=null;}",
    "try{r.color_depth=typeof screen==='object'&&screen&&typeof screen.colorDepth==='number'?screen.colorDepth:null;}catch(e){r.color_depth=null;}",
    "try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(e){r.webdriver=null;}",
    "return JSON.stringify(r);",
    "})()"
);

const IFRAME_PROBE: &str = concat!(
    "(function(){",
    "try{",
    "var doc=document;",
    "var body=doc.body||doc.documentElement;",
    "if(!body){return JSON.stringify({skipped:'no document body'});}",
    "var f=doc.createElement('iframe');",
    "f.setAttribute('aria-hidden','true');",
    "f.style.cssText='position:absolute;width:0;height:0;border:0;visibility:hidden;';",
    "f.srcdoc='<!doctype html><html><head></head><body></body></html>';",
    "body.appendChild(f);",
    "var w=f.contentWindow;",
    "if(!w){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe contentWindow unavailable'});}",
    "var n=w.navigator;",
    "if(!n){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe navigator unavailable'});}",
    "var r={};",
    "try{r.user_agent=String(n.userAgent||'');}catch(e){r.user_agent='';}",
    "try{r.platform=String(n.platform||'');}catch(e){r.platform='';}",
    "try{r.languages=Array.isArray(n.languages)?n.languages.join(','):'';}catch(e){r.languages='';}",
    "try{r.hardware_concurrency=typeof n.hardwareConcurrency==='number'?n.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
    "try{r.device_memory=typeof n.deviceMemory==='number'?n.deviceMemory:null;}catch(e){r.device_memory=null;}",
    "try{r.timezone=String(w.Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
    "try{r.screen_width=w.screen&&typeof w.screen.width==='number'?w.screen.width:null;}catch(e){r.screen_width=null;}",
    "try{r.screen_height=w.screen&&typeof w.screen.height==='number'?w.screen.height:null;}catch(e){r.screen_height=null;}",
    "try{r.color_depth=w.screen&&typeof w.screen.colorDepth==='number'?w.screen.colorDepth:null;}catch(e){r.color_depth=null;}",
    "try{r.webdriver=typeof n.webdriver==='boolean'?n.webdriver:null;}catch(e){r.webdriver=null;}",
    "try{body.removeChild(f);}catch(_e){}",
    "return JSON.stringify(r);",
    "}catch(e){",
    "return JSON.stringify({skipped:'iframe probe failed: '+(e&&e.message?e.message:String(e))});",
    "}",
    "})()"
);

// The worker probe embeds a multi-line JS program as a string
// passed to `new Blob([...])`. The script is a JS string literal in
// the host probe; the Rust raw string below contains that JS string
// verbatim (with `\n` → newline in JS, because the entire raw
// string is the JS source).
const WORKER_PROBE: &str = r#"(function(){
try{
if(typeof Worker==='undefined'){return JSON.stringify({skipped:'Worker unsupported'});}
if(typeof Blob==='undefined'||typeof URL==='undefined'||typeof URL.createObjectURL!=='function'){return JSON.stringify({skipped:'Blob/URL.createObjectURL unsupported'});}
var src="self.onmessage=function(_e){var r={};try{r.user_agent=String(navigator.userAgent||'');}catch(_err){r.user_agent='';}try{r.platform=String(navigator.platform||'');}catch(_err){r.platform='';}try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(_err){r.languages='';}try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(_err){r.hardware_concurrency=null;}try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(_err){r.device_memory=null;}try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(_err){r.timezone='';}try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(_err){r.webdriver=null;}try{self.postMessage(JSON.stringify(r));self.close();}catch(_err){self.postMessage('SKIP:'+(_err&&_err.message?_err.message:String(_err)));self.close();}};";
var blob;
try{blob=new Blob([src],{type:'application/javascript'});}catch(e){return JSON.stringify({skipped:'Blob construction failed: '+(e&&e.message?e.message:String(e))});}
var url;
try{url=URL.createObjectURL(blob);}catch(e){return JSON.stringify({skipped:'createObjectURL failed: '+(e&&e.message?e.message:String(e))});}
var worker;
try{worker=new Worker(url);}catch(e){try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'new Worker failed: '+(e&&e.message?e.message:String(e))});}
var result=null;
var errorReason='';
worker.onmessage=function(ev){result=ev&&typeof ev.data==='string'?ev.data:null;};
worker.onerror=function(ev){errorReason=(ev&&ev.message)?ev.message:'unknown worker error';};
try{worker.postMessage('probe');}catch(e){try{worker.terminate();}catch(_e){} try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'postMessage failed: '+(e&&e.message?e.message:String(e))});}
var deadline=Date.now()+2000;
while(result===null&&errorReason===''&&Date.now()<deadline){}
try{worker.terminate();}catch(_e){}
try{URL.revokeObjectURL(url);}catch(_e){}
if(errorReason!==''){return JSON.stringify({skipped:'worker error: '+errorReason});}
if(result===null){return JSON.stringify({skipped:'worker probe timeout'});}
if(typeof result==='string'&&result.indexOf('SKIP:')===0){return JSON.stringify({skipped:result.substring(5)});}
return result;
}catch(e){
return JSON.stringify({skipped:'worker probe failed: '+(e&&e.message?e.message:String(e))});
}
})()"#;

// ─── CoherenceProbe runner ────────────────────────────────────────────────────

/// Runner that executes the cross-context identity probes via CDP and
/// aggregates the results into a [`CoherenceDriftReport`][super::CoherenceDriftReport].
///
/// `CoherenceProbe` is the **default-on** entry point for cross-context
/// stealth coherence. Worker probes are best-effort: when the runtime
/// does not expose `Worker`/`Blob`/`URL.createObjectURL`, the worker
/// slot in the report is populated with
/// [`ContextObservation::Skipped`] rather than panicking.
///
/// # Idempotence
///
/// All probe methods are safely re-runnable on the same page; they
/// do not mutate the DOM beyond a transient iframe that is removed
/// at the end of the probe, and the worker is `terminate()`-ed and
/// its `Blob` URL is `revokeObjectURL`-ed before the probe returns.
///
/// # Feature flag
///
/// The coherence module is **default-on**; no feature gate is
/// required. The probe runner requires a live browser page (i.e.
/// the existing `browser-cdp` capability, which is the
/// `stygian-browser` default).
///
/// # Example
///
/// ```no_run
/// # async fn run() -> stygian_browser::error::Result<()> {
/// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
/// use stygian_browser::coherence::CoherenceProbe;
/// use std::time::Duration;
///
/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
/// let handle = pool.acquire().await?;
/// let mut page = handle
///     .browser()
///     .expect("valid browser")
///     .new_page()
///     .await?;
/// page.navigate(
///     "https://example.com",
///     WaitUntil::DomContentLoaded,
///     Duration::from_secs(30),
/// )
/// .await?;
///
/// let probe = CoherenceProbe::default();
/// let report = probe.run(&page).await?;
/// println!(
///     "coherent={} hard_drift={} contexts={}/3",
///     report.is_coherent(),
///     report.has_hard_drift(),
///     report.observed_context_count(),
/// );
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Default)]
pub struct CoherenceProbe {
    _private: (),
}

impl CoherenceProbe {
    /// Build a new runner with default settings.
    #[must_use]
    pub const fn new() -> Self {
        Self { _private: () }
    }

    /// Run all three probes (top, iframe, worker) and build a
    /// [`CoherenceDriftReport`][super::CoherenceDriftReport].
    ///
    /// Per-context probe failures never abort the run; the failing
    /// context is recorded as a [`Skipped`][ContextObservation::Skipped]
    /// marker.
    ///
    /// # Errors
    ///
    /// Returns [`BrowserError`] only when CDP itself is unreachable
    /// (no live browser connection). Per-probe script errors are
    /// captured as skipped observations.
    pub async fn run(&self, page: &PageHandle) -> Result<super::CoherenceDriftReport> {
        let top = self.probe_top(page).await;
        let iframe = self.probe_iframe(page).await;
        let worker = self.probe_worker(page).await;
        Ok(super::report::build_report(top, iframe, worker, None))
    }

    /// Run all three probes **and** evaluate a
    /// [`crate::freshness::FreshnessContract`] against the
    /// top-level identity signature.
    ///
    /// The contract is checked against the top-level
    /// [`IdentitySurface`] signature; the resulting
    /// [`crate::freshness::FreshnessReport`] is attached to the
    /// returned drift report under [`super::CoherenceDriftReport::freshness`].
    ///
    /// # Errors
    ///
    /// See [`Self::run`].
    pub async fn run_with_freshness(
        &self,
        page: &PageHandle,
        contract: &crate::freshness::FreshnessContract,
    ) -> Result<super::CoherenceDriftReport> {
        let top = self.probe_top(page).await;
        let iframe = self.probe_iframe(page).await;
        let worker = self.probe_worker(page).await;

        // Build the freshness check input from the top-level surface
        // signature, when available.
        let freshness_report = match &top {
            ContextObservation::Observed { surface } => {
                let observed_signature = super::report::surface_signature(surface);
                let input = crate::freshness::FreshnessCheckInput::new(
                    &contract.domain,
                    Some(observed_signature.as_str()),
                    crate::freshness::unix_epoch_ms(),
                );
                let report = crate::freshness::FreshnessReport::evaluate(contract, &input);
                report.log();
                Some(report)
            }
            ContextObservation::Skipped { .. } => None,
        };

        Ok(super::report::build_report(
            top,
            iframe,
            worker,
            freshness_report,
        ))
    }

    /// Run only the top-level document probe.
    ///
    /// Useful for unit tests or callers that want a single-context
    /// snapshot without paying for the iframe + worker probes.
    ///
    /// # Errors
    ///
    /// Returns [`BrowserError`] on CDP failure. Script errors
    /// surface as a [`Skipped`][ContextObservation::Skipped]
    /// observation.
    pub async fn probe_top(&self, page: &PageHandle) -> ContextObservation {
        run_probe(page, TOP_LEVEL_PROBE, "top-level").await
    }

    /// Run only the same-origin iframe probe.
    ///
    /// # Errors
    ///
    /// Returns [`BrowserError`] on CDP failure. Script errors
    /// surface as a [`Skipped`][ContextObservation::Skipped]
    /// observation.
    pub async fn probe_iframe(&self, page: &PageHandle) -> ContextObservation {
        run_probe(page, IFRAME_PROBE, "iframe").await
    }

    /// Run only the dedicated/shared worker probe.
    ///
    /// # Errors
    ///
    /// Returns [`BrowserError`] on CDP failure. Script errors
    /// surface as a [`Skipped`][ContextObservation::Skipped]
    /// observation.
    pub async fn probe_worker(&self, page: &PageHandle) -> ContextObservation {
        run_probe(page, WORKER_PROBE, "worker").await
    }
}

// ─── Internal: run a single probe and decode the JSON envelope ────────────────

async fn run_probe(page: &PageHandle, script: &str, label: &'static str) -> ContextObservation {
    let json: String = match page.eval(script).await {
        Ok(s) => s,
        Err(err) => {
            tracing::warn!(
                context = label,
                error = %err,
                "coherence probe CDP evaluation failed",
            );
            return ContextObservation::skipped(format!("{label} CDP failure: {err}"));
        }
    };
    match serde_json::from_str::<ProbeOutput>(&json) {
        Ok(out) => out.into_observation(),
        Err(err) => {
            tracing::warn!(
                context = label,
                error = %err,
                raw = %json,
                "coherence probe returned invalid JSON",
            );
            ContextObservation::skipped(format!("{label} JSON decode failed: {err}"))
        }
    }
}

// Compile-time guarantee that the public error type accepts our
// `Result` alias; silences dead-code lints on `BrowserError` in
// feature combinations where the import would otherwise be unused.
#[allow(dead_code)]
const _: fn() -> Option<BrowserError> = || None;

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing
)]
mod tests {
    use super::*;
    use crate::coherence::report::{ContextKind, ContextPair, DriftSeverity, build_report};

    #[test]
    fn top_level_probe_uses_old_style_function_and_json_stringify() {
        // Old-style IIFE + var for broadest browser-engine compatibility
        assert!(TOP_LEVEL_PROBE.starts_with("(function(){"));
        assert!(TOP_LEVEL_PROBE.contains("var r={};"));
        assert!(TOP_LEVEL_PROBE.contains("JSON.stringify(r)"));
        assert!(!TOP_LEVEL_PROBE.contains("=>"), "no arrow functions");
        assert!(!TOP_LEVEL_PROBE.contains("let "), "no `let` declarations");
        assert!(
            !TOP_LEVEL_PROBE.contains("const "),
            "no `const` declarations"
        );
    }

    #[test]
    fn iframe_probe_creates_srcdoc_iframe() {
        assert!(IFRAME_PROBE.contains("srcdoc="));
        assert!(IFRAME_PROBE.contains("createElement('iframe')"));
        assert!(IFRAME_PROBE.contains("appendChild"));
        // Cleanup is required for idempotence
        assert!(IFRAME_PROBE.contains("removeChild"));
    }

    #[test]
    fn worker_probe_uses_blob_url_and_terminates() {
        assert!(WORKER_PROBE.contains("new Worker("));
        assert!(WORKER_PROBE.contains("createObjectURL"));
        assert!(WORKER_PROBE.contains("terminate"));
        assert!(WORKER_PROBE.contains("revokeObjectURL"));
        // Bounded busy-wait so the runner can never block indefinitely
        assert!(WORKER_PROBE.contains("Date.now()+2000"));
    }

    #[test]
    fn all_probes_wrap_in_iife() {
        for (name, script) in [
            ("top", TOP_LEVEL_PROBE),
            ("iframe", IFRAME_PROBE),
            ("worker", WORKER_PROBE),
        ] {
            let trimmed = script.trim_start();
            assert!(
                trimmed.starts_with("(function(){"),
                "{name} probe must be a self-invoking function expression"
            );
            assert!(
                script.trim_end().ends_with(")()"),
                "{name} probe must end with the IIFE invocation"
            );
        }
    }

    #[test]
    fn probe_output_skipped_decodes_to_observation() {
        let raw = r#"{"skipped":"Worker unsupported"}"#;
        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
        let obs = parsed.into_observation();
        assert!(obs.is_skipped());
        assert!(!obs.is_observed());
    }

    #[test]
    fn probe_output_observed_decodes_to_observation() {
        let raw = r#"{"user_agent":"Mozilla/5.0","platform":"MacIntel","languages":"en-US","hardware_concurrency":8,"device_memory":8,"timezone":"UTC","screen_width":1920,"screen_height":1080,"color_depth":24,"webdriver":false}"#;
        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
        let obs = parsed.into_observation();
        let surface = obs.surface().expect("surface present");
        assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
        assert_eq!(surface.hardware_concurrency, Some(8));
        assert_eq!(surface.webdriver, Some(false));
    }

    #[test]
    fn probe_output_partial_surface_decodes_cleanly() {
        // Missing fields are allowed; the deserialiser fills them with None.
        let raw = r#"{"user_agent":"Mozilla/5.0"}"#;
        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
        let surface = parsed
            .into_observation()
            .surface()
            .expect("surface")
            .clone();
        assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
        assert!(surface.platform.is_none());
        assert!(surface.webdriver.is_none());
    }

    #[test]
    fn probe_output_invalid_falls_back_to_observed_with_empty_surface() {
        // A bare `null` is not a valid struct; the runner treats
        // JSON decode failures as a `Skipped` marker (see
        // `run_probe`), not a panicking decode error.
        let raw = "null";
        let parsed: serde_json::Result<ProbeOutput> = serde_json::from_str(raw);
        assert!(parsed.is_err());

        // An empty `{}` IS a valid struct — it decodes to a default
        // (empty) `IdentitySurface` with no `skipped` marker, which
        // is the contract for "context probed but produced nothing".
        let parsed: ProbeOutput = serde_json::from_str("{}").expect("decode empty object");
        let obs = parsed.into_observation();
        assert!(obs.is_observed());
        assert!(obs.surface().expect("surface").is_empty());
    }

    #[test]
    fn build_report_with_three_skipped_contexts_emits_no_drift() {
        let report = build_report(
            ContextObservation::skipped("a"),
            ContextObservation::skipped("b"),
            ContextObservation::skipped("c"),
            None,
        );
        assert!(report.is_coherent());
        assert_eq!(report.observed_context_count(), 0);
        assert_eq!(report.skipped_context_count(), 3);
    }

    #[test]
    fn coherence_probe_default_is_constructible() {
        let _ = CoherenceProbe::default();
        let _ = CoherenceProbe::new();
    }

    #[test]
    fn drift_severity_classifies_hard_fields() {
        assert_eq!(
            super::super::report::field_severity("user_agent"),
            DriftSeverity::Hard
        );
        assert_eq!(
            super::super::report::field_severity("platform"),
            DriftSeverity::Hard
        );
        assert_eq!(
            super::super::report::field_severity("languages"),
            DriftSeverity::Hard
        );
        assert_eq!(
            super::super::report::field_severity("webdriver"),
            DriftSeverity::Hard
        );
        assert_eq!(
            super::super::report::field_severity("hardware_concurrency"),
            DriftSeverity::KnownLimitation
        );
        assert_eq!(
            super::super::report::field_severity("device_memory"),
            DriftSeverity::KnownLimitation
        );
    }

    #[test]
    fn context_kind_constants_resolve_internally() {
        // Sanity-check the sides() resolver used by the comparison
        // helpers; this catches typos in the ContextPair → ContextKind
        // mapping before they reach a live browser.
        assert_eq!(
            ContextPair::TopIframe.sides(),
            (ContextKind::Top, ContextKind::Iframe)
        );
        assert_eq!(
            ContextPair::TopWorker.sides(),
            (ContextKind::Top, ContextKind::Worker)
        );
        assert_eq!(
            ContextPair::IframeWorker.sides(),
            (ContextKind::Iframe, ContextKind::Worker)
        );
    }
}
