use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::browser::backend::BrowserBackend;
use crate::pipe::{Action, CommandOutput, ExecutionSurfaceMetadata, PipeError, Timing};
use crate::security::MacPolicy;

const CLICK_PROBE_CALLBACK_NAME: &str = "sgclawOnClickProbe";
const TYPE_PROBE_CALLBACK_NAME: &str = "sgclawOnTypeProbe";
const GET_TEXT_CALLBACK_NAME: &str = "sgclawOnGetText";
const EVAL_CALLBACK_NAME: &str = "sgclawOnEval";
const SHOW_AREA: &str = "show";
const LOCAL_DASHBOARD_EXPECTED_DOMAIN: &str = "__sgclaw_local_dashboard__";
const LOCAL_DASHBOARD_SOURCE: &str = "compat.workflow_executor";
const LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN: &str = "zhihu_hotlist_screen";

pub trait BrowserCallbackHost: Send + Sync {
    fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError>;
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackRequest {
    pub seq: u64,
    pub request_url: String,
    pub expected_domain: String,
    pub action: String,
    pub command: Value,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BrowserCallbackResponse {
    Success(BrowserCallbackSuccess),
    Error(BrowserCallbackError),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackSuccess {
    pub success: bool,
    pub data: Value,
    pub aom_snapshot: Vec<Value>,
    pub timing: Timing,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserCallbackError {
    pub message: String,
    pub details: Value,
}

pub struct BrowserCallbackBackend {
    host: Arc<dyn BrowserCallbackHost>,
    mac_policy: MacPolicy,
    helper_page_url: String,
    current_target_url: Mutex<Option<String>>,
    next_seq: AtomicU64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CallbackInputMode {
    Click,
    Type,
}

impl BrowserCallbackBackend {
    pub fn new(
        host: Arc<dyn BrowserCallbackHost>,
        mac_policy: MacPolicy,
        helper_page_url: impl Into<String>,
    ) -> Self {
        Self {
            host,
            mac_policy,
            helper_page_url: helper_page_url.into(),
            current_target_url: Mutex::new(None),
            next_seq: AtomicU64::new(1),
        }
    }

    fn build_command(&self, action: &Action, params: &Value) -> Result<Value, PipeError> {
        match action {
            Action::Navigate => {
                let target_url = required_string(params, "url")?;
                // Use sgBrowerserOpenPage to open the target URL in a **new**
                // visible browser tab.  This keeps the helper page alive so its
                // WebSocket connection, command polling, and callback functions
                // remain functional for subsequent GetText / Eval commands.
                //
                // sgBrowserCallAfterLoaded would navigate the helper page tab
                // itself to the target URL, destroying all helper-page JS
                // context and making further communication impossible.
                //
                // sgBrowerserOpenPage does not fire a JS callback; the callback
                // host will treat the navigate action as fire-and-forget and
                // return success once the command has been forwarded.
                Ok(json!([
                    self.helper_page_url,
                    "sgBrowerserOpenPage",
                    target_url,
                ]))
            }
            Action::Click => self.build_input_command(action, params, CallbackInputMode::Click),
            Action::Type => self.build_input_command(action, params, CallbackInputMode::Type),
            Action::GetText => {
                let target_url = self.target_url(action, params)?;
                let domain = extract_domain(&target_url)?;
                let selector = required_string(params, "selector")?;
                let js_code = build_get_text_js(&self.helper_page_url, &selector);
                // Use sgBrowserExcuteJsCodeByDomain (API #25) which matches
                // pages by domain rather than exact URL.  This is far more
                // robust than sgBrowserExcuteJsCodeByArea because the actual
                // page URL may differ from what we navigated to (redirects,
                // query parameters, etc.).
                Ok(json!([
                    self.helper_page_url,
                    "sgBrowserExcuteJsCodeByDomain",
                    domain,
                    js_code,
                    SHOW_AREA,
                ]))
            }
            Action::Eval => {
                let target_url = self.target_url(action, params)?;
                let domain = extract_domain(&target_url)?;
                let script = required_string(params, "script")?;
                let js_code = build_eval_js(&self.helper_page_url, &script);
                Ok(json!([
                    self.helper_page_url,
                    "sgBrowserExcuteJsCodeByDomain",
                    domain,
                    js_code,
                    SHOW_AREA,
                ]))
            }
            _ => Err(PipeError::Protocol(format!(
                "unsupported callback-host browser action: {}",
                action.as_str()
            ))),
        }
    }

    fn build_input_command(
        &self,
        action: &Action,
        params: &Value,
        mode: CallbackInputMode,
    ) -> Result<Value, PipeError> {
        let target_url = self.target_url(action, params)?;
        let domain = extract_domain(&target_url)?;
        let selector = optional_string(params, "selector");
        let probe_script = optional_string(params, "probe_script");
        let text = matches!(mode, CallbackInputMode::Type)
            .then(|| required_string(params, "text"))
            .transpose()?;
        let js_code = build_input_probe_js(
            mode,
            &self.helper_page_url,
            selector.as_deref(),
            probe_script.as_deref(),
            text.as_deref(),
        )?;
        Ok(json!([
            self.helper_page_url,
            "sgBrowserExcuteJsCodeByDomain",
            domain,
            js_code,
            SHOW_AREA,
        ]))
    }

    fn target_url(&self, action: &Action, params: &Value) -> Result<String, PipeError> {
        if let Some(target_url) = params
            .get("target_url")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
        {
            return Ok(target_url);
        }

        self.current_target_url
            .lock()
            .map_err(|_| PipeError::Protocol("callback backend target url lock poisoned".to_string()))?
            .clone()
            .ok_or_else(|| PipeError::Protocol(format!("target_url is required for {}", action.as_str())))
    }

    fn execute_simulated_click(
        &self,
        seq: u64,
        expected_domain: &str,
        success: &BrowserCallbackSuccess,
    ) -> Result<BrowserCallbackSuccess, PipeError> {
        let probe = success
            .data
            .get("probe")
            .ok_or_else(|| PipeError::Protocol("callback click probe payload missing".to_string()))?;
        let x = probe
            .get("x")
            .and_then(Value::as_f64)
            .ok_or_else(|| PipeError::Protocol("callback click probe missing x".to_string()))?;
        let y = probe
            .get("y")
            .and_then(Value::as_f64)
            .ok_or_else(|| PipeError::Protocol("callback click probe missing y".to_string()))?;
        let timing = success.timing.clone();
        match self.host.execute(BrowserCallbackRequest {
            seq,
            request_url: self.helper_page_url.clone(),
            expected_domain: expected_domain.to_string(),
            action: Action::Click.as_str().to_string(),
            command: json!([
                self.helper_page_url,
                "sgBroewserSimulateMouse",
                x,
                y,
                "left",
                "",
                ""
            ]),
        }) {
            Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
                "callback host browser action failed: {} ({})",
                error.message, error.details
            ))),
            Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
                Ok(BrowserCallbackSuccess {
                    success: true,
                    data: json!({
                        "clicked": true,
                        "probe": { "x": x, "y": y },
                    }),
                    aom_snapshot: vec![],
                    timing,
                })
            }
            Err(error) => Err(error),
        }
    }

    fn execute_simulated_type(
        &self,
        seq: u64,
        expected_domain: &str,
        params: &Value,
        success: &BrowserCallbackSuccess,
    ) -> Result<BrowserCallbackSuccess, PipeError> {
        let probe = success
            .data
            .get("probe")
            .ok_or_else(|| PipeError::Protocol("callback type probe payload missing".to_string()))?;
        let x = probe
            .get("x")
            .and_then(Value::as_f64)
            .ok_or_else(|| PipeError::Protocol("callback type probe missing x".to_string()))?;
        let y = probe
            .get("y")
            .and_then(Value::as_f64)
            .ok_or_else(|| PipeError::Protocol("callback type probe missing y".to_string()))?;
        let text = params
            .get("text")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| PipeError::Protocol("text is required".to_string()))?;
        let timing = success.timing.clone();
        match self.host.execute(BrowserCallbackRequest {
            seq,
            request_url: self.helper_page_url.clone(),
            expected_domain: expected_domain.to_string(),
            action: Action::Type.as_str().to_string(),
            command: json!([
                self.helper_page_url,
                "sgBroewserSimulateKeyborad",
                x,
                y,
                text
            ]),
        }) {
            Ok(BrowserCallbackResponse::Error(error)) => Err(PipeError::Protocol(format!(
                "callback host browser action failed: {} ({})",
                error.message, error.details
            ))),
            Ok(BrowserCallbackResponse::Success(_)) | Err(PipeError::Timeout) => {
                Ok(BrowserCallbackSuccess {
                    success: true,
                    data: json!({
                        "typed": true,
                        "probe": { "x": x, "y": y, "text": text },
                    }),
                    aom_snapshot: vec![],
                    timing,
                })
            }
            Err(error) => Err(error),
        }
    }
}

impl BrowserBackend for BrowserCallbackBackend {
    fn invoke(
        &self,
        action: Action,
        params: Value,
        expected_domain: &str,
    ) -> Result<CommandOutput, PipeError> {
        if let Some(local_dashboard) = approved_local_dashboard_request(&action, &params, expected_domain)
        {
            self.mac_policy
                .validate_local_dashboard_presentation(
                    &action,
                    expected_domain,
                    &local_dashboard.presentation_url,
                    &local_dashboard.output_path,
                )
                .map_err(PipeError::Security)?;
        } else {
            self.mac_policy
                .validate(&action, expected_domain)
                .map_err(PipeError::Security)?;
        }

        let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
        let reply = self.host.execute(BrowserCallbackRequest {
            seq,
            request_url: self.helper_page_url.clone(),
            expected_domain: expected_domain.to_string(),
            action: action.as_str().to_string(),
            command: self.build_command(&action, &params)?,
        })?;

        match reply {
            BrowserCallbackResponse::Success(success) => {
                let success = match action {
                    Action::Click => self.execute_simulated_click(seq, expected_domain, &success)?,
                    Action::Type => {
                        self.execute_simulated_type(seq, expected_domain, &params, &success)?
                    }
                    _ => success,
                };
                if matches!(action, Action::Navigate) {
                    if let Some(url) = params
                        .get("url")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|value| !value.is_empty())
                    {
                        *self.current_target_url.lock().map_err(|_| {
                            PipeError::Protocol("callback backend target url lock poisoned".to_string())
                        })? = Some(url.to_string());
                    }
                }
                Ok(CommandOutput {
                    seq,
                    success: success.success,
                    data: success.data,
                    aom_snapshot: success.aom_snapshot,
                    timing: success.timing,
                })
            }
            BrowserCallbackResponse::Error(error) => Err(PipeError::Protocol(format!(
                "callback host browser action failed: {} ({})",
                error.message, error.details
            ))),
        }
    }

    fn surface_metadata(&self) -> ExecutionSurfaceMetadata {
        self.mac_policy.privileged_surface_metadata()
    }

    fn supports_eval(&self) -> bool {
        self.mac_policy.supports_pipe_action(&Action::Eval)
    }

    fn supports_live_input(&self) -> bool {
        self.mac_policy.supports_pipe_action(&Action::Click)
            && self.mac_policy.supports_pipe_action(&Action::Type)
    }
}

fn required_string(params: &Value, key: &str) -> Result<String, PipeError> {
    params
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| PipeError::Protocol(format!("{key} is required")))
}

fn optional_string(params: &Value, key: &str) -> Option<String> {
    params
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

fn build_get_text_js(source_url: &str, selector: &str) -> String {
    let escaped_source_url = escape_js_single_quoted(source_url);
    let escaped_selector = escape_js_single_quoted(selector);
    let callback = GET_TEXT_CALLBACK_NAME;
    let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));

    // Three delivery paths for getting the result back to the callback host:
    //
    //   1. callBackJsToCpp (API #40) — browser-native IPC that routes the
    //      callback function to the helper page.
    //   2. XMLHttpRequest POST to callback host — localhost (127.0.0.1) is
    //      exempt from mixed-content restrictions in Chromium.
    //   3. navigator.sendBeacon fallback — same localhost exemption.
    //
    // The XHR / sendBeacon paths POST the event DIRECTLY in the format the
    // callback host expects (callback="sgclawOnGetText", payload={text:...})
    // so normalize_callback_result can process it via Path A.
    format!(
        "(function(){{try{{\
         var el=document.querySelector('{escaped_selector}');\
         var t=el?((el.innerText||el.textContent||'').trim()):'';\
         try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+t)}}catch(_){{}}\
         var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{text:t}}}});\
         try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
         try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
         }}catch(e){{}}}})()"
    )
}

fn build_eval_js(source_url: &str, script: &str) -> String {
    let escaped_source_url = escape_js_single_quoted(source_url);
    let callback = EVAL_CALLBACK_NAME;
    let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));

    format!(
        "(async function(){{try{{\
         var v=await (async function(){{return {script}}})();\
         if(v&&typeof v.then==='function'){{v=await v;}}\
         var t=(typeof v==='string')?v:JSON.stringify(v);\
         try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+(t??''))}}catch(_){{}}\
         var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{{value:(t??'')}}}});\
         try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
         try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
         }}catch(e){{}}}})()"
    )
}

fn build_input_probe_js(
    mode: CallbackInputMode,
    source_url: &str,
    selector: Option<&str>,
    probe_script: Option<&str>,
    text: Option<&str>,
) -> Result<String, PipeError> {
    let escaped_source_url = escape_js_single_quoted(source_url);
    let callback = match mode {
        CallbackInputMode::Click => CLICK_PROBE_CALLBACK_NAME,
        CallbackInputMode::Type => TYPE_PROBE_CALLBACK_NAME,
    };
    let events_url = escape_js_single_quoted(&events_endpoint_url(source_url));
    let payload_expression = match mode {
        CallbackInputMode::Click => "JSON.stringify({x:x,y:y})".to_string(),
        CallbackInputMode::Type => {
            let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
            format!("JSON.stringify({{x:x,y:y,text:'{escaped_text}'}})")
        }
    };
    let payload_object = match mode {
        CallbackInputMode::Click => "{x:x,y:y}".to_string(),
        CallbackInputMode::Type => {
            let escaped_text = escape_js_single_quoted(text.unwrap_or_default());
            format!("{{x:x,y:y,text:'{escaped_text}'}}")
        }
    };
    let element_lookup = if let Some(script) = probe_script {
        format!("(function(){{{script}}})()")
    } else if let Some(selector) = selector {
        let escaped_selector = escape_js_single_quoted(selector);
        format!("document.querySelector('{escaped_selector}')")
    } else {
        return Err(PipeError::Protocol(
            "selector or probe_script is required".to_string(),
        ));
    };
    let missing_hint = selector
        .map(|value| format!("selector not found: {}", escape_js_single_quoted(value)))
        .unwrap_or_else(|| "input probe target not found".to_string());

    Ok(format!(
        "(function(){{try{{\
         var el={element_lookup};\
         if(!el){{throw new Error('{missing_hint}');}}\
         var rect=(typeof el.getBoundingClientRect==='function')?el.getBoundingClientRect():null;\
         var x=rect?(rect.left+(rect.width/2)):0;\
         var y=rect?(rect.top+(rect.height/2)):0;\
         try{{callBackJsToCpp('{escaped_source_url}@_@'+window.location.href+'@_@{callback}@_@sgBrowserExcuteJsCodeByDomain@_@'+String({payload_expression}))}}catch(_){{}}\
         var j=JSON.stringify({{type:'callback',callback:'{callback}',request_url:'{escaped_source_url}',payload:{payload_object}}});\
         try{{var r=new XMLHttpRequest();r.open('POST','{events_url}',true);r.setRequestHeader('Content-Type','application/json');r.send(j)}}catch(_){{}}\
         try{{navigator.sendBeacon('{events_url}',new Blob([j],{{type:'application/json'}}))}}catch(_){{}}\
         }}catch(e){{}}}})()"
    ))
}

/// Derive the callback host events endpoint URL from the helper page URL.
/// e.g. "http://127.0.0.1:62819/sgclaw/browser-helper.html"
///    → "http://127.0.0.1:62819/sgclaw/callback/events"
fn events_endpoint_url(helper_page_url: &str) -> String {
    let origin = helper_page_url
        .find("://")
        .and_then(|scheme_end| {
            helper_page_url[scheme_end + 3..]
                .find('/')
                .map(|path_start| &helper_page_url[..scheme_end + 3 + path_start])
        })
        .unwrap_or(helper_page_url);
    format!("{origin}/sgclaw/callback/events")
}

/// Extract the domain from a URL.
/// e.g. "https://www.zhihu.com/hot" → "www.zhihu.com"
fn extract_domain(url: &str) -> Result<String, PipeError> {
    let after_scheme = url
        .find("://")
        .map(|i| &url[i + 3..])
        .unwrap_or(url);
    let domain = after_scheme
        .split('/')
        .next()
        .unwrap_or(after_scheme)
        .split(':')
        .next()
        .unwrap_or(after_scheme);
    if domain.is_empty() {
        return Err(PipeError::Protocol(format!(
            "failed to extract domain from URL: {url}"
        )));
    }
    Ok(domain.to_string())
}

fn escape_js_single_quoted(raw: &str) -> String {
    raw.replace('\\', "\\\\")
        .replace('\'', "\\'")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\0', "\\0")
        .replace('\u{2028}', "\\u2028")
        .replace('\u{2029}', "\\u2029")
}

struct LocalDashboardRequest {
    presentation_url: String,
    output_path: String,
}

fn approved_local_dashboard_request(
    action: &Action,
    params: &Value,
    expected_domain: &str,
) -> Option<LocalDashboardRequest> {
    if action != &Action::Navigate || expected_domain != LOCAL_DASHBOARD_EXPECTED_DOMAIN {
        return None;
    }

    let presentation_url = params.get("url")?.as_str()?.trim();
    let marker = params.get("sgclaw_local_dashboard_open")?.as_object()?;
    let source = marker.get("source")?.as_str()?.trim();
    let kind = marker.get("kind")?.as_str()?.trim();
    let output_path = marker.get("output_path")?.as_str()?.trim();
    let marker_presentation_url = marker.get("presentation_url")?.as_str()?.trim();

    if source != LOCAL_DASHBOARD_SOURCE
        || kind != LOCAL_DASHBOARD_KIND_ZHIHU_HOTLIST_SCREEN
        || output_path.is_empty()
        || presentation_url.is_empty()
        || marker_presentation_url != presentation_url
    {
        return None;
    }

    Some(LocalDashboardRequest {
        presentation_url: presentation_url.to_string(),
        output_path: output_path.to_string(),
    })
}

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

    fn test_policy() -> MacPolicy {
        MacPolicy::from_json_str(
            r#"{
                "version": "1.0",
                "domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] },
                "pipe_actions": {
                    "allowed": ["click", "type", "navigate", "getText", "eval"],
                    "blocked": []
                }
            }"#,
        )
        .unwrap()
    }

    struct FakeCallbackHost {
        requests: Mutex<Vec<BrowserCallbackRequest>>,
        replies: Mutex<VecDeque<Result<BrowserCallbackResponse, PipeError>>>,
    }

    impl FakeCallbackHost {
        fn new(replies: Vec<Result<BrowserCallbackResponse, PipeError>>) -> Self {
            Self {
                requests: Mutex::new(Vec::new()),
                replies: Mutex::new(VecDeque::from(replies)),
            }
        }

        fn requests(&self) -> Vec<BrowserCallbackRequest> {
            self.requests.lock().unwrap().clone()
        }
    }

    impl BrowserCallbackHost for FakeCallbackHost {
        fn execute(&self, request: BrowserCallbackRequest) -> Result<BrowserCallbackResponse, PipeError> {
            self.requests.lock().unwrap().push(request);
            self.replies
                .lock()
                .unwrap()
                .pop_front()
                .unwrap_or_else(|| Err(PipeError::Timeout))
        }
    }

    fn success_reply(data: Value) -> Result<BrowserCallbackResponse, PipeError> {
        Ok(BrowserCallbackResponse::Success(BrowserCallbackSuccess {
            success: true,
            data,
            aom_snapshot: vec![],
            timing: Timing {
                queue_ms: 1,
                exec_ms: 1,
            },
        }))
    }

    #[test]
    fn callback_backend_click_treats_simulated_mouse_follow_up_as_fire_and_forget() {
        let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
            json!({ "probe": { "x": 320.5, "y": 240.25 } }),
        )]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Click,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "selector": "button"
                }),
                "zhuanlan.zhihu.com",
            )
            .unwrap();

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
        assert_eq!(requests[1].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBroewserSimulateMouse",
            320.5,
            240.25,
            "left",
            "",
            ""
        ]));
    }

    #[test]
    fn callback_backend_click_survives_simulated_mouse_timeout() {
        let host = Arc::new(FakeCallbackHost::new(vec![
            success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
            Err(PipeError::Timeout),
        ]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Click,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "selector": "button"
                }),
                "zhuanlan.zhihu.com",
            )
            .expect("simulated mouse timeout should be treated as fire-and-forget success");

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
    }

    #[test]
    fn callback_backend_click_uses_domain_probe_then_simulated_mouse_input() {
        let host = Arc::new(FakeCallbackHost::new(vec![
            success_reply(json!({ "probe": { "x": 320.5, "y": 240.25 } })),
            success_reply(json!({ "clicked": true })),
        ]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Click,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "selector": "button"
                }),
                "zhuanlan.zhihu.com",
            )
            .unwrap();

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
        assert_eq!(requests[0].action, "click");
        assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
        assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
        let script = requests[0].command[3].as_str().unwrap();
        assert!(script.contains("document.querySelector('button')"));
        assert!(script.contains("sgclawOnClick"));
        assert_eq!(requests[1].action, "click");
        assert_eq!(requests[1].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBroewserSimulateMouse",
            320.5,
            240.25,
            "left",
            "",
            ""
        ]));
    }

    #[test]
    fn callback_backend_type_treats_simulated_keyboard_follow_up_as_fire_and_forget() {
        let host = Arc::new(FakeCallbackHost::new(vec![success_reply(
            json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } }),
        )]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Type,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "selector": "div[contenteditable='true']",
                    "text": "正文"
                }),
                "zhuanlan.zhihu.com",
            )
            .unwrap();

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
        assert_eq!(requests[1].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBroewserSimulateKeyborad",
            160.0,
            90.0,
            "正文"
        ]));
    }

    #[test]
    fn callback_backend_type_uses_custom_probe_script_when_provided() {
        let host = Arc::new(FakeCallbackHost::new(vec![
            success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
            success_reply(json!({ "typed": true })),
        ]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Type,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "probe_script": "return document.body;",
                    "text": "正文"
                }),
                "zhuanlan.zhihu.com",
            )
            .unwrap();

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
        let script = requests[0].command[3].as_str().unwrap();
        assert!(script.contains("return document.body;"));
        assert!(!script.contains("selector not found: div[contenteditable='true']"));
        assert_eq!(requests[1].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBroewserSimulateKeyborad",
            160.0,
            90.0,
            "正文"
        ]));
    }

    #[test]
    fn callback_backend_type_uses_domain_probe_then_simulated_keyboard_input() {
        let host = Arc::new(FakeCallbackHost::new(vec![
            success_reply(json!({ "probe": { "x": 160.0, "y": 90.0, "text": "正文" } })),
            success_reply(json!({ "typed": true })),
        ]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Type,
                json!({
                    "target_url": "https://zhuanlan.zhihu.com/write",
                    "selector": "div[contenteditable='true']",
                    "text": "正文"
                }),
                "zhuanlan.zhihu.com",
            )
            .unwrap();

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 2);
        assert_eq!(requests[0].action, "type");
        assert_eq!(requests[0].command[1], json!("sgBrowserExcuteJsCodeByDomain"));
        assert_eq!(requests[0].command[2], json!("zhuanlan.zhihu.com"));
        let script = requests[0].command[3].as_str().unwrap();
        assert!(script.contains("document.querySelector('div[contenteditable=\\'true\\']')"));
        assert!(script.contains("sgclawOnType"));
        assert!(!script.contains("el.value="));
        assert_eq!(requests[1].action, "type");
        assert_eq!(requests[1].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBroewserSimulateKeyborad",
            160.0,
            90.0,
            "正文"
        ]));
    }

    #[test]
    fn callback_backend_accepts_approved_local_dashboard_navigate_request() {
        let host = Arc::new(FakeCallbackHost::new(vec![success_reply(json!({
            "navigated": true
        }))]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let output = backend
            .invoke(
                Action::Navigate,
                json!({
                    "url": "file:///C:/tmp/zhihu-hotlist-screen.html",
                    "sgclaw_local_dashboard_open": {
                        "source": "compat.workflow_executor",
                        "kind": "zhihu_hotlist_screen",
                        "output_path": "C:/tmp/zhihu-hotlist-screen.html",
                        "presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
                    }
                }),
                "__sgclaw_local_dashboard__",
            )
            .expect("approved local dashboard request should be accepted");

        assert!(output.success);
        let requests = host.requests();
        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].command, json!([
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            "sgBrowerserOpenPage",
            "file:///C:/tmp/zhihu-hotlist-screen.html"
        ]));
    }

    #[test]
    fn callback_backend_rejects_local_dashboard_navigate_without_required_marker_fields() {
        let host = Arc::new(FakeCallbackHost::new(vec![]));
        let backend = BrowserCallbackBackend::new(
            host.clone(),
            test_policy(),
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
        );

        let err = backend
            .invoke(
                Action::Navigate,
                json!({
                    "url": "file:///C:/tmp/zhihu-hotlist-screen.html",
                    "sgclaw_local_dashboard_open": {
                        "source": "compat.workflow_executor",
                        "kind": "zhihu_hotlist_screen",
                        "presentation_url": "file:///C:/tmp/zhihu-hotlist-screen.html"
                    }
                }),
                "__sgclaw_local_dashboard__",
            )
            .unwrap_err();

        assert!(host.requests().is_empty());
        assert!(err.to_string().contains("domain is not allowed"));
    }

    #[test]
    fn escape_js_single_quoted_escapes_newlines_and_control_chars() {
        let raw = "第一行\n第二行\r\n第三行";
        let escaped = escape_js_single_quoted(raw);
        assert!(!escaped.contains('\n'), "literal newline must be escaped");
        assert!(!escaped.contains('\r'), "literal carriage return must be escaped");
        assert!(escaped.contains("\\n"), "should contain escaped newline");
        assert!(escaped.contains("\\r"), "should contain escaped carriage return");
        assert_eq!(escaped, "第一行\\n第二行\\r\\n第三行");
    }

    #[test]
    fn type_probe_script_with_multiline_text_is_valid_js() {
        let text_with_newlines = "标题\n\n正文第一段\n正文第二段";
        let js = build_input_probe_js(
            CallbackInputMode::Type,
            "http://127.0.0.1:17888/sgclaw/browser-helper.html",
            Some("div[contenteditable='true']"),
            None,
            Some(text_with_newlines),
        )
        .unwrap();
        // The generated JS must NOT contain literal newlines inside single-quoted strings.
        // Split on single quotes and check inner segments.
        assert!(
            !js.contains("标题\n"),
            "literal newline must not appear in the JS probe script"
        );
        assert!(js.contains("标题\\n"));
        assert!(js.contains("sgclawOnTypeProbe"));
    }
}
