//! Shared browser-run logic for the FFI bindings.
//!
//! A `BrowserSession` lazily launches Chromium on first `run()` call, and
//! keeps the session alive for the lifetime of the CarRuntime instance so
//! element IDs from `observe` resolve correctly across multiple `run()`
//! invocations. Mirrors the `car browse run` CLI surface — same JSON
//! script shape in, same JSON trace shape out.

use std::sync::Arc;

use car_browser::backend::BrowserBackend;
use car_browser::chromium::{ChromiumBackend, LaunchOptions};
use car_browser::models::{CookieParam, Modifier};

pub use car_browser::chromium::LaunchOptions as BrowserLaunchOptions;
use car_browser::perception::pipeline::{BasicPerceptionPipeline, PerceptionPipeline};
use car_browser::perception::ui_map::UiMap;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::Mutex;

/// Resettable holder for a lazily launched browser session.
///
/// `tokio::sync::OnceCell` is a bad fit here because `browser.close` needs to
/// invalidate the cached session. Otherwise the next `browser.run` reuses a
/// closed Chromium backend forever.
#[derive(Default)]
pub struct BrowserSessionSlot {
    session: Mutex<Option<Arc<BrowserSession>>>,
}

impl BrowserSessionSlot {
    pub fn new() -> Self {
        Self::default()
    }

    pub async fn get_or_launch(&self, opts: LaunchOptions) -> Result<Arc<BrowserSession>, String> {
        let mut guard = self.session.lock().await;
        if let Some(session) = guard.as_ref() {
            return Ok(session.clone());
        }

        let session = Arc::new(BrowserSession::launch_with_options(opts).await?);
        *guard = Some(session.clone());
        Ok(session)
    }

    pub async fn close(&self) -> Result<bool, String> {
        let session = self.session.lock().await.take();
        if let Some(session) = session {
            session.close().await?;
            Ok(true)
        } else {
            Ok(false)
        }
    }
}

/// A reusable browser session — wraps Chromium + a perception pipeline and
/// caches the last observed UiMap for element-ID resolution.
///
/// # Lifecycle
///
/// Callers should invoke [`BrowserSession::close`] before dropping when a
/// clean teardown matters. The `Drop` impl runs a best-effort shutdown
/// using `tokio::task::block_in_place` when a runtime is available, but
/// it can leak the Chromium process in edge cases (e.g. when the tokio
/// runtime is already shutting down). Explicit `close().await` is the
/// reliable path.
pub struct BrowserSession {
    backend: Arc<ChromiumBackend>,
    pipeline: BasicPerceptionPipeline,
    last_ui_map: Mutex<Option<UiMap>>,
    width: u32,
    height: u32,
    closed: std::sync::atomic::AtomicBool,
}

impl BrowserSession {
    pub async fn launch(width: u32, height: u32) -> Result<Self, String> {
        Self::launch_with_options(LaunchOptions {
            width,
            height,
            headless: true,
            extra_args: Vec::new(),
        })
        .await
    }

    /// Launch with full options (width, height, headless). Used by the FFI
    /// bindings to expose a headed mode for interactive first-time auth.
    pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, String> {
        let width = opts.width;
        let height = opts.height;
        let backend = Arc::new(
            ChromiumBackend::launch_with_options(opts)
                .await
                .map_err(|e| format!("launch chrome: {}", e))?,
        );
        Ok(Self {
            backend,
            pipeline: BasicPerceptionPipeline::new(),
            last_ui_map: Mutex::new(None),
            width,
            height,
            closed: std::sync::atomic::AtomicBool::new(false),
        })
    }

    /// Viewport dimensions the session was launched with.
    pub fn viewport(&self) -> (u32, u32) {
        (self.width, self.height)
    }

    /// Shut down the Chromium process cleanly. Idempotent — calling twice
    /// is safe.
    pub async fn close(&self) -> Result<(), String> {
        use std::sync::atomic::Ordering;
        if self.closed.swap(true, Ordering::SeqCst) {
            return Ok(());
        }
        self.backend
            .shutdown()
            .await
            .map_err(|e| format!("browser shutdown: {}", e))
    }

    /// Run a JSON script of operations against the open browser.
    /// Returns the JSON trace (caller parses if it wants structured access).
    pub async fn run(&self, script_json: &str) -> Result<String, String> {
        let script: BrowseScript =
            serde_json::from_str(script_json).map_err(|e| format!("parse script: {}", e))?;

        // Expand `session_ref` (if any) by reading a SessionBundle from the
        // secret store and prepending set_cookies / set_local_storage /
        // set_extra_headers ops. Real credentials never appear in the script
        // JSON this way — only the reference.
        let expanded = expand_session_ref(script.session_ref.as_ref(), script.operations)?;

        let mut steps = Vec::with_capacity(expanded.len());
        for op in expanded {
            let start = std::time::Instant::now();
            let (name, result) = self.run_op(op).await;
            let elapsed = start.elapsed().as_millis() as u64;
            let (status, data, error) = match result {
                Ok(d) => ("ok", d, None),
                Err(e) => ("error", None, Some(e)),
            };
            let failed = status == "error";
            steps.push(json!({
                "op": name,
                "status": status,
                "data": data,
                "error": error,
                "duration_ms": elapsed,
            }));
            if failed {
                break;
            }
        }

        Ok(json!({ "steps": steps }).to_string())
    }

    async fn run_op(&self, op: BrowseOp) -> (&'static str, Result<Option<Value>, String>) {
        match op {
            BrowseOp::Navigate { url } => (
                "navigate",
                self.backend
                    .navigate(&url)
                    .await
                    .map(|_| Some(json!({ "url": url })))
                    .map_err(|e| e.to_string()),
            ),
            BrowseOp::Observe => {
                let res: Result<Option<Value>, String> = async {
                    let tree = self
                        .backend
                        .get_accessibility_tree()
                        .await
                        .map_err(|e| e.to_string())?;
                    let viewport = self.backend.get_viewport().map_err(|e| e.to_string())?;
                    let url = self.backend.get_current_url().map_err(|e| e.to_string())?;
                    let title = self.backend.get_page_title().await.unwrap_or_default();
                    let screenshot = self
                        .backend
                        .capture_screenshot()
                        .await
                        .map_err(|e| e.to_string())?;
                    let ui_map = self
                        .pipeline
                        .perceive(&screenshot, &tree, &url, viewport)
                        .await
                        .map_err(|e| e.to_string())?;
                    let summary = ui_map.format_summary();
                    let element_count = ui_map.elements.len();
                    *self.last_ui_map.lock().await = Some(ui_map);
                    Ok(Some(json!({
                        "url": url,
                        "title": title,
                        "element_count": element_count,
                        "summary": summary,
                    })))
                }
                .await;
                ("observe", res)
            }
            BrowseOp::Click { element_id } => {
                let ax_id = self.resolve_element(&element_id).await;
                (
                    "click",
                    self.backend
                        .click_element(&ax_id)
                        .await
                        .map(|_| Some(json!({ "element_id": element_id, "resolved_to": ax_id })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::Type { element_id, text } => {
                let ax_id = self.resolve_element(&element_id).await;
                (
                    "type",
                    self.backend
                        .type_into_element(&ax_id, &text)
                        .await
                        .map(|_| Some(json!({ "element_id": element_id, "resolved_to": ax_id })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::Scroll { delta_y } => (
                "scroll",
                self.backend
                    .inject_scroll(delta_y)
                    .await
                    .map(|_| Some(json!({ "delta_y": delta_y })))
                    .map_err(|e| e.to_string()),
            ),
            BrowseOp::Keypress { key, modifiers } => {
                let parsed: Vec<Modifier> = modifiers
                    .iter()
                    .filter_map(|m| match m.to_lowercase().as_str() {
                        "alt" => Some(Modifier::Alt),
                        "control" | "ctrl" => Some(Modifier::Control),
                        "meta" | "command" | "cmd" => Some(Modifier::Meta),
                        "shift" => Some(Modifier::Shift),
                        _ => None,
                    })
                    .collect();
                (
                    "keypress",
                    self.backend
                        .inject_keypress(&key, &parsed)
                        .await
                        .map(|_| Some(json!({ "key": key, "modifiers": modifiers })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::SetCookies { cookies } => {
                let count = cookies.len();
                (
                    "set_cookies",
                    self.backend
                        .set_cookies(&cookies)
                        .await
                        .map(|_| Some(json!({ "cookies_set": count })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::SetExtraHeaders { headers } => {
                let count = headers.len();
                let pairs: Vec<(String, String)> = headers.into_iter().collect();
                (
                    "set_extra_headers",
                    self.backend
                        .set_extra_headers(&pairs)
                        .await
                        .map(|_| Some(json!({ "headers_set": count })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::SetLocalStorage { origin, items } => {
                let count = items.len();
                let pairs: Vec<(String, String)> = items.into_iter().collect();
                (
                    "set_local_storage",
                    self.backend
                        .set_local_storage(&origin, &pairs)
                        .await
                        .map(|_| Some(json!({ "origin": origin, "items_set": count })))
                        .map_err(|e| e.to_string()),
                )
            }
            BrowseOp::Wait {
                condition,
                timeout_ms,
            } => {
                let parsed = match parse_wait_condition(&condition) {
                    Some(c) => c,
                    None => {
                        return (
                            "wait",
                            Err(format!("unknown wait condition: {}", condition)),
                        );
                    }
                };
                (
                    "wait",
                    self.backend
                        .wait_until(&parsed, timeout_ms)
                        .await
                        .map(|success| Some(json!({ "condition": condition, "success": success })))
                        .map_err(|e| e.to_string()),
                )
            }
        }
    }
}

impl Drop for BrowserSession {
    fn drop(&mut self) {
        use std::sync::atomic::Ordering;
        if self.closed.load(Ordering::SeqCst) {
            return;
        }
        // Best effort: only works if a tokio runtime exists and we're not
        // inside `drop_in_place` during final-shutdown teardown. Callers
        // who care about clean teardown should call `close().await`.
        let backend = self.backend.clone();
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            handle.spawn(async move {
                let _ = backend.shutdown().await;
            });
        }
    }
}

impl BrowserSession {
    async fn resolve_element(&self, element_id: &str) -> String {
        let guard = self.last_ui_map.lock().await;
        if let Some(map) = guard.as_ref() {
            if let Some(el) = map.get_element(element_id) {
                if let Some(ref ax) = el.ax_ref {
                    return ax.clone();
                }
            }
        }
        element_id.to_string()
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowseScript {
    operations: Vec<BrowseOp>,
    /// Optional reference to a stored session bundle. When present, the
    /// browser reads `SessionBundle` JSON from the secret store and
    /// prepends auto-replayable ops (cookies + localStorage) to the
    /// script. Headers are NOT auto-replayed — see `expand_session_ref`.
    #[serde(default)]
    session_ref: Option<SessionRef>,
}

/// Points at a stored `SessionBundle` in the OS secret store. Matches the
/// shape that `car secret put` / `rt.secret_put` writes.
#[derive(Deserialize)]
pub struct SessionRef {
    /// Namespace (service) in the OS secret store.
    #[serde(default)]
    pub service: Option<String>,
    /// Entry key — the actual (service, key) pair the secret is stored under.
    pub key: String,
}

/// Persistent session material users store via the secret API and reference
/// from scripts via `session_ref`. All fields are optional — the bundle only
/// replays what's present.
#[derive(Deserialize, serde::Serialize)]
pub struct SessionBundle {
    #[serde(default)]
    pub cookies: Vec<CookieParam>,
    #[serde(default)]
    pub headers: std::collections::HashMap<String, String>,
    /// origin → {key: value}
    #[serde(default)]
    pub local_storage: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
}

/// Read the session bundle (if referenced) and prepend the corresponding
/// ops to the script. Errors cleanly if the ref is present but the secret
/// store is unavailable or the entry isn't a valid bundle.
///
/// # Security contract
///
/// `session_ref` deliberately does NOT auto-replay `headers` from the
/// bundle. Chromium's `Network.setExtraHTTPHeaders` has no per-origin
/// scoping, so headers injected automatically would follow every
/// navigation — a malicious script that references an attacker-controlled
/// URL after a legitimate session would leak `Authorization: Bearer ...`
/// and friends.
///
/// Cookies (domain-scoped) and localStorage (origin-scoped) are safe to
/// auto-replay because the browser enforces scoping at the protocol
/// level. Headers must be added with an explicit `set_extra_headers` op,
/// with the author accepting responsibility for cross-origin exposure.
///
/// The stored `SessionBundle.headers` field is retained so users can
/// store + inspect + manually replay headers; it is never auto-injected.
fn expand_session_ref(
    session_ref: Option<&SessionRef>,
    ops: Vec<BrowseOp>,
) -> Result<Vec<BrowseOp>, String> {
    let sref = match session_ref {
        Some(s) => s,
        None => return Ok(ops),
    };

    let raw = crate::secrets::read_raw(sref.service.as_deref(), &sref.key)
        .map_err(|e| format!("session_ref: {}", e))?;
    let bundle: SessionBundle = serde_json::from_str(&raw)
        .map_err(|e| format!("session_ref value is not a valid SessionBundle: {}", e))?;

    let mut expanded = Vec::with_capacity(ops.len() + 2);
    if !bundle.cookies.is_empty() {
        expanded.push(BrowseOp::SetCookies {
            cookies: bundle.cookies,
        });
    }
    // NOTE: bundle.headers is NOT auto-injected. See security contract above.
    // Callers that need headers replay them explicitly via set_extra_headers.
    for (origin, items) in bundle.local_storage {
        expanded.push(BrowseOp::SetLocalStorage { origin, items });
    }
    expanded.extend(ops);
    Ok(expanded)
}

#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
enum BrowseOp {
    Navigate {
        url: String,
    },
    Observe,
    Click {
        element_id: String,
    },
    Type {
        element_id: String,
        text: String,
    },
    Scroll {
        delta_y: i32,
    },
    Keypress {
        key: String,
        #[serde(default)]
        modifiers: Vec<String>,
    },
    Wait {
        condition: String,
        #[serde(default = "default_wait_timeout_ms")]
        timeout_ms: u64,
    },
    /// Inject cookies before the next navigation.
    SetCookies {
        cookies: Vec<CookieParam>,
    },
    /// Set extra HTTP headers to include on every request.
    SetExtraHeaders {
        headers: std::collections::HashMap<String, String>,
    },
    /// Set localStorage items for an origin.
    SetLocalStorage {
        origin: String,
        items: std::collections::HashMap<String, String>,
    },
}

fn default_wait_timeout_ms() -> u64 {
    30_000
}

fn parse_wait_condition(s: &str) -> Option<car_browser::models::WaitCondition> {
    use car_browser::models::WaitCondition;
    match s {
        "page_loaded" => Some(WaitCondition::PageLoaded),
        "url_changed" => Some(WaitCondition::UrlChanged),
        s if s.starts_with("element_with_name:") => Some(WaitCondition::ElementWithName {
            name_contains: s[18..].to_string(),
            role: None,
        }),
        s if s.starts_with("a11y_contains_text:") => Some(WaitCondition::A11yContainsText {
            text: s[19..].to_string(),
        }),
        _ => None,
    }
}
