use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;

use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use tokio::process::Command;

use crate::config::{BrowserConfig, expand_path};

pub(super) struct AgentBrowserRunner {
    command: String,
    workspace_dir: PathBuf,
    executable_path: Option<String>,
    headless: bool,
    timeout: Duration,
    max_output_chars: usize,
    content_boundaries: bool,
    allowed_domains: Vec<String>,
    transient_idle_timeout_ms: u64,
}

impl AgentBrowserRunner {
    pub(super) fn new(config: &BrowserConfig, workspace_dir: PathBuf) -> Self {
        let executable_path = config.browser_executable_path.as_deref().map(|path| {
            let path = expand_path(path);
            let path = if path.is_absolute() {
                path
            } else {
                workspace_dir.join(path)
            };
            path.to_string_lossy().into_owned()
        });
        Self {
            command: config.command.clone(),
            workspace_dir,
            executable_path,
            headless: config.headless,
            timeout: Duration::from_secs(config.command_timeout_secs.max(1)),
            max_output_chars: config.max_output_chars.max(1),
            content_boundaries: config.content_boundaries,
            allowed_domains: config.allowed_domains.clone(),
            transient_idle_timeout_ms: config.idle_timeout_secs.saturating_mul(1_000),
        }
    }

    pub(super) async fn run(
        &self,
        session_id: &str,
        profile_dir: Option<&std::path::Path>,
        args: &[String],
    ) -> Result<Value> {
        let mut command = Command::new(&self.command);
        command
            .arg("--session")
            .arg(session_id)
            .arg("--json")
            .arg("--headed")
            .arg(if self.headless { "false" } else { "true" });
        if let Some(profile_dir) = profile_dir {
            command.arg("--profile").arg(profile_dir);
        }
        command
            .args(args)
            .current_dir(&self.workspace_dir)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .kill_on_drop(true)
            .env(
                "AGENT_BROWSER_CONTENT_BOUNDARIES",
                if self.content_boundaries { "1" } else { "0" },
            )
            .env(
                "AGENT_BROWSER_MAX_OUTPUT",
                self.max_output_chars.to_string(),
            )
            .env(
                "AGENT_BROWSER_IDLE_TIMEOUT_MS",
                if profile_dir.is_some() {
                    "0".to_string()
                } else {
                    self.transient_idle_timeout_ms.to_string()
                },
            );
        if let Some(path) = &self.executable_path {
            command.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
        }
        if !self.allowed_domains.is_empty() {
            command.env(
                "AGENT_BROWSER_ALLOWED_DOMAINS",
                self.allowed_domains.join(","),
            );
        }

        let output = tokio::time::timeout(self.timeout, command.output())
            .await
            .map_err(|_| {
                anyhow!(
                    "agent-browser command timed out after {} seconds",
                    self.timeout.as_secs()
                )
            })?
            .with_context(|| {
                format!(
                    "failed to start '{}'; install with `npm install -g agent-browser@0.33.0` (or the documented Cargo/Homebrew method), then run `agent-browser install` and `picobot health`",
                    self.command,
                )
            })?;

        let stdout = truncate_bytes(&output.stdout, self.max_output_chars.saturating_mul(4));
        let stderr = truncate_bytes(&output.stderr, self.max_output_chars.min(8_000));
        let parsed = parse_json_output(&stdout);
        if !output.status.success() {
            let detail = parsed
                .as_ref()
                .and_then(extract_error)
                .or_else(|| nonempty(&stderr))
                .or_else(|| nonempty(&stdout))
                .unwrap_or("agent-browser exited without an error message");
            bail!("agent-browser failed: {}", actionable_error(detail));
        }
        let response = parsed.ok_or_else(|| {
            anyhow!(
                "agent-browser returned invalid JSON: {}",
                nonempty(&stdout).unwrap_or("empty output")
            )
        })?;
        if response.get("success").and_then(Value::as_bool) == Some(false) {
            bail!(
                "agent-browser failed: {}",
                actionable_error(extract_error(&response).unwrap_or("unknown browser error"))
            );
        }
        Ok(response)
    }

    pub(super) fn render_response(&self, response: &Value) -> String {
        let data = response.get("data").unwrap_or(response);
        let mut rendered = ["snapshot", "text", "title", "url", "message", "path"]
            .iter()
            .find_map(|key| data.get(key).and_then(Value::as_str))
            .map(str::to_string)
            .unwrap_or_else(|| match data {
                Value::String(value) => value.clone(),
                other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
            });
        if let Some(boundary) = response.get("_boundary") {
            rendered.push_str("\n\nagent-browser boundary metadata: ");
            rendered.push_str(&boundary.to_string());
        }
        truncate_string(&rendered, self.max_output_chars)
    }
}

fn parse_json_output(output: &str) -> Option<Value> {
    serde_json::from_str(output.trim()).ok().or_else(|| {
        output
            .lines()
            .rev()
            .find_map(|line| serde_json::from_str(line.trim()).ok())
    })
}

fn extract_error(response: &Value) -> Option<&str> {
    response
        .get("error")
        .and_then(|error| error.as_str().or_else(|| error.get("message")?.as_str()))
        .or_else(|| response.get("message").and_then(Value::as_str))
}

fn truncate_bytes(bytes: &[u8], max: usize) -> String {
    let value = String::from_utf8_lossy(bytes);
    truncate_string(&value, max)
}

fn truncate_string(value: &str, max: usize) -> String {
    if value.len() <= max {
        return value.to_string();
    }
    let end = value.floor_char_boundary(max);
    format!("{}\n... [output truncated by PicoBot]", &value[..end])
}

fn nonempty(value: &str) -> Option<&str> {
    let value = value.trim();
    (!value.is_empty()).then_some(value)
}

fn actionable_error(detail: &str) -> String {
    let lower = detail.to_ascii_lowercase();
    if lower.contains("shared librar")
        || lower.contains("missing dependenc")
        || lower.contains("error while loading shared")
    {
        format!(
            "{detail}\nInstall Linux browser dependencies with `agent-browser install --with-deps`, then run `picobot health`."
        )
    } else if lower.contains("chrome")
        || lower.contains("chromium")
        || lower.contains("browser executable")
        || lower.contains("browser not found")
    {
        format!(
            "{detail}\nInstall a browser with `agent-browser install`, or set browser.browser_executable_path / AGENT_BROWSER_EXECUTABLE_PATH, then run `picobot health`."
        )
    } else {
        detail.to_string()
    }
}

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

    #[test]
    fn parses_json_after_a_warning_line() {
        let value =
            parse_json_output("warning\n{\"success\":true,\"data\":{\"title\":\"Hi\"}}").unwrap();
        assert_eq!(value["data"]["title"], "Hi");
    }

    #[test]
    fn missing_browser_error_includes_install_action() {
        let error = actionable_error("Chrome executable not found");
        assert!(error.contains("agent-browser install"));
        assert!(error.contains("browser.browser_executable_path"));
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn persistent_profile_path_is_forwarded_to_agent_browser() {
        use std::os::unix::fs::PermissionsExt;

        let temp = tempfile::tempdir().unwrap();
        let script = temp.path().join("fake-agent-browser");
        let args_file = temp.path().join("args.txt");
        let idle_timeout_file = temp.path().join("idle-timeout.txt");
        std::fs::write(
            &script,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' \"$AGENT_BROWSER_IDLE_TIMEOUT_MS\" > '{}'\nprintf '%s\\n' '{{\"success\":true,\"data\":{{\"message\":\"ok\"}}}}'\n",
                args_file.display(),
                idle_timeout_file.display()
            ),
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
        let config = BrowserConfig {
            command: script.to_string_lossy().into_owned(),
            ..BrowserConfig::default()
        };
        let runner = AgentBrowserRunner::new(&config, temp.path().to_path_buf());
        let profile = temp.path().join("profile");

        runner
            .run(
                "persistent-id",
                Some(&profile),
                &["open".to_string(), "https://example.com".to_string()],
            )
            .await
            .unwrap();

        let args = std::fs::read_to_string(args_file).unwrap();
        let args: Vec<_> = args.lines().collect();
        assert!(
            args.windows(2)
                .any(|pair| pair == ["--session", "persistent-id"])
        );
        assert!(args.windows(2).any(|pair| {
            pair[0] == "--profile" && pair[1] == profile.to_string_lossy().as_ref()
        }));
        assert_eq!(std::fs::read_to_string(idle_timeout_file).unwrap(), "0\n");
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn transient_session_uses_configured_idle_timeout() {
        use std::os::unix::fs::PermissionsExt;

        let temp = tempfile::tempdir().unwrap();
        let script = temp.path().join("fake-agent-browser");
        let idle_timeout_file = temp.path().join("idle-timeout.txt");
        std::fs::write(
            &script,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$AGENT_BROWSER_IDLE_TIMEOUT_MS\" > '{}'\nprintf '%s\\n' '{{\"success\":true,\"data\":{{\"message\":\"ok\"}}}}'\n",
                idle_timeout_file.display()
            ),
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
        let config = BrowserConfig {
            command: script.to_string_lossy().into_owned(),
            idle_timeout_secs: 37,
            ..BrowserConfig::default()
        };
        let runner = AgentBrowserRunner::new(&config, temp.path().to_path_buf());

        runner
            .run(
                "transient-id",
                None,
                &["open".to_string(), "https://example.com".to_string()],
            )
            .await
            .unwrap();

        assert_eq!(
            std::fs::read_to_string(idle_timeout_file).unwrap(),
            "37000\n"
        );
    }
}
