use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};

use crate::systemd_compat;

use super::run_process;

pub const DUNE_APP_ID: u32 = 4_754_530;

/// Result of `steamcmd ... +app_info_print <id>` parsing.
#[derive(Debug, Clone)]
pub struct AppInfoBuild {
    pub buildid: String,
}

#[derive(Clone)]
pub struct SteamCmd {
    bin: PathBuf,
    download_path: PathBuf,
    home_path: PathBuf,
}

impl SteamCmd {
    pub fn new(bin: PathBuf, download_path: PathBuf, home_path: PathBuf) -> Self {
        Self {
            bin,
            download_path,
            home_path,
        }
    }

    pub fn bin_path(&self) -> &Path {
        &self.bin
    }

    /// Ensure a steamcmd shell wrapper exists at `bin_path`. Idempotent. The
    /// wrapper probes for the registered user's Steam install first, then
    /// falls back to a system `steamcmd` (`/usr/bin/steamcmd`, common on
    /// Alpine). It always cd's into `download_path` so the vendor's
    /// relative-path assumptions hold.
    pub fn ensure_wrapper(&self) -> anyhow::Result<()> {
        use anyhow::Context;
        if self.bin.exists() {
            return Ok(());
        }
        if let Some(parent) = self.bin.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        std::fs::create_dir_all(&self.download_path)
            .with_context(|| format!("creating {}", self.download_path.display()))?;

        let home_path = shell_single_quoted(&self.home_path.to_string_lossy());
        let download_path = shell_single_quoted(&self.download_path.to_string_lossy());
        let steamcmd_sh = self.home_path.join("Steam").join("steamcmd.sh");
        let steamcmd_sh_quoted = shell_single_quoted(&steamcmd_sh.to_string_lossy());
        let body = format!(
            "#!/usr/bin/env sh\n\
             # Auto-generated by dune-server-service. Multi-backend steamcmd wrapper.\n\
             set -eu\n\
             export HOME={home_path}\n\
             cd {download_path}\n\
             if [ -x {steamcmd_sh_quoted} ]; then\n  \
                 exec {steamcmd_sh_quoted} \"$@\"\n\
             elif [ -x /usr/bin/steamcmd ]; then\n  \
                 exec /usr/bin/steamcmd \"$@\"\n\
             else\n  \
                 echo \"no steamcmd backend found (need {steamcmd_sh} or /usr/bin/steamcmd)\" >&2\n  \
                 exit 1\n\
             fi\n",
            home_path = home_path,
            download_path = download_path,
            steamcmd_sh = steamcmd_sh.display(),
            steamcmd_sh_quoted = steamcmd_sh_quoted,
        );

        std::fs::write(&self.bin, body)
            .with_context(|| format!("writing wrapper {}", self.bin.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&self.bin)?.permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&self.bin, perms)?;
        }
        tracing::info!(path = %self.bin.display(), "created steamcmd wrapper");
        Ok(())
    }

    pub fn manifest_path(&self) -> PathBuf {
        self.download_path
            .join("steamapps")
            .join(format!("appmanifest_{DUNE_APP_ID}.acf"))
    }

    pub fn downloaded_version_path(&self) -> PathBuf {
        self.download_path
            .join("images")
            .join("battlegroup")
            .join("version.txt")
    }

    /// Run steamcmd against the public branch and pluck the `buildid` out of
    /// the `app_info_print` output for the Dune app. Retries once on cold-cache
    /// runs where the first `app_info_update` returns an empty payload.
    pub async fn latest_public_build(&self) -> Result<AppInfoBuild> {
        let bin = self.bin.to_string_lossy().into_owned();
        let id = DUNE_APP_ID.to_string();
        let mut last_stdout = String::new();
        let mut last_stderr = String::new();
        let mut last_exit: i32 = 0;
        for attempt in 1..=2 {
            let result = run_process(
                &bin,
                &[
                    "+login",
                    "anonymous",
                    "+app_info_update",
                    "1",
                    "+app_info_print",
                    &id,
                    "+quit",
                ],
                None,
                180,
            )
            .await
            .context("invoking steamcmd app_info_print")?;
            last_exit = result.exit_code;
            last_stdout = result.stdout;
            last_stderr = result.stderr;
            let combined = format!("{last_stdout}\n{last_stderr}");
            if systemd_compat::steamcmd_relocation_blocked(&combined) {
                match systemd_compat::repair_after_steamcmd_relocation_failure() {
                    Ok(true) => {
                        return Err(anyhow!(
                            "steamcmd was blocked by systemd MemoryDenyWriteExecute; installed compatibility override and requested dune-server-service restart"
                        ));
                    }
                    Ok(false) => {
                        return Err(anyhow!(
                            "steamcmd was blocked by systemd MemoryDenyWriteExecute, but no systemd repair was applied; reinstall or update the management service unit"
                        ));
                    }
                    Err(repair_err) => {
                        return Err(anyhow!(
                            "steamcmd was blocked by systemd MemoryDenyWriteExecute and automatic repair failed: {repair_err:#}"
                        ));
                    }
                }
            }
            if let Some(buildid) = parse_buildid_from_app_info(&last_stdout)
                .or_else(|| parse_buildid_from_app_info(&last_stderr))
            {
                return Ok(AppInfoBuild { buildid });
            }
            if attempt == 1 {
                // Cold-cache: first app_info_update sometimes just primes Steam's
                // local cache without returning branch detail. A second pass picks
                // it up.
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
            }
        }
        Err(anyhow!(
            "could not determine latest Steam buildid from app_info_print (exit {last_exit}); stdout_tail: {tail}; stderr_tail: {err_tail}",
            tail = tail(&last_stdout, 1200),
            err_tail = tail(&last_stderr, 400),
        ))
    }

    /// Read the local appmanifest_*.acf and pluck the local buildid.
    pub async fn local_build(&self) -> Result<Option<String>> {
        let path = self.manifest_path();
        if !path.exists() {
            return Ok(None);
        }
        let contents = tokio::fs::read_to_string(&path)
            .await
            .with_context(|| format!("reading {}", path.display()))?;
        Ok(parse_buildid_from_acf(&contents))
    }

    /// Read the downloaded `images/battlegroup/version.txt` file produced by
    /// the vendor download flow. None when missing/empty.
    pub async fn downloaded_version(&self) -> Result<Option<String>> {
        let path = self.downloaded_version_path();
        if !path.exists() {
            return Ok(None);
        }
        let contents = tokio::fs::read_to_string(&path)
            .await
            .with_context(|| format!("reading {}", path.display()))?;
        let trimmed: String = contents.chars().filter(|c| !c.is_whitespace()).collect();
        if trimmed.is_empty() {
            Ok(None)
        } else {
            Ok(Some(trimmed))
        }
    }

    /// `steamcmd +force_install_dir <path> +login anonymous +app_update <id> +quit`.
    pub async fn download_update(&self) -> Result<()> {
        let bin = self.bin.to_string_lossy().into_owned();
        let install_dir = self.download_path.to_string_lossy().into_owned();
        let id = DUNE_APP_ID.to_string();
        let result = run_process(
            &bin,
            &[
                "+set_spew_level",
                "1",
                "1",
                "+force_install_dir",
                &install_dir,
                "+login",
                "anonymous",
                "+app_update",
                &id,
                "+logoff",
                "+quit",
            ],
            None,
            1800,
        )
        .await
        .context("invoking steamcmd app_update")?;
        result.require_ok("steamcmd app_update")
    }

    pub fn download_path(&self) -> &Path {
        &self.download_path
    }
}

fn shell_single_quoted(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\"'\"'"))
}

/// Last `max` bytes of `s`, trimmed and prefixed with `…` if truncated. Used
/// to include steamcmd output in error messages without flooding the log.
/// UTF-8 safe — snaps to the next char boundary if `max` lands inside one.
fn tail(s: &str, max: usize) -> String {
    let trimmed = s.trim();
    if trimmed.len() <= max {
        return trimmed.replace('\n', " ⏎ ");
    }
    let mut start = trimmed.len() - max;
    while start < trimmed.len() && !trimmed.is_char_boundary(start) {
        start += 1;
    }
    let mut out = String::from("…");
    out.push_str(&trimmed[start..].replace('\n', " ⏎ "));
    out
}

/// Parse the `buildid` value from `steamcmd +app_info_print` stdout. We rely
/// on the same minimal state-machine the original awk script used: locate the
/// "public" key inside the "branches" block, then the next "buildid" line.
fn parse_buildid_from_app_info(stdout: &str) -> Option<String> {
    let mut in_branches = false;
    let mut in_public = false;
    for line in stdout.lines() {
        let trimmed = line.trim();
        if trimmed.contains("\"branches\"") {
            in_branches = true;
            continue;
        }
        if in_branches && trimmed.contains("\"public\"") {
            in_public = true;
            continue;
        }
        if in_public && trimmed.contains("\"buildid\"") {
            // line shape: "buildid"		"12345"
            let mut parts = trimmed.split_whitespace();
            parts.next(); // "buildid"
            if let Some(val) = parts.next() {
                let cleaned = val.trim_matches('"');
                if !cleaned.is_empty() {
                    return Some(cleaned.to_string());
                }
            }
        }
    }
    None
}

/// Parse the `buildid` value from an `appmanifest_*.acf` file.
/// VDF format: `"buildid"  "12345"` with whitespace between key and value.
fn parse_buildid_from_acf(text: &str) -> Option<String> {
    for line in text.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("\"buildid\"") {
            continue;
        }
        let mut parts = trimmed.split_whitespace();
        parts.next();
        if let Some(val) = parts.next() {
            let cleaned = val.trim_matches('"');
            if !cleaned.is_empty() {
                return Some(cleaned.to_string());
            }
        }
    }
    None
}

/// Extract the seabass-server image version tag (e.g. `12345-0-abcde`) from a
/// BattleGroup JSON spec. Walks any nested object looking for `image:` strings
/// pointing at seabass-server, regex-matches the version suffix, returns the
/// minimum (lexicographic) version it finds — mirrors the python helper used
/// by `cron-battlegroup-update-check`.
pub fn extract_live_version(bg_json: &serde_json::Value) -> Option<String> {
    use once_cell::sync::Lazy;
    use regex::Regex;
    static IMAGE_RE: Lazy<Regex> =
        Lazy::new(|| Regex::new(r"/seabass-server(?::|[^:]*:)([0-9]+-0-[A-Za-z0-9_-]+)$").unwrap());
    let mut versions: Vec<String> = Vec::new();
    walk(bg_json, &mut |v| {
        if let serde_json::Value::String(s) = v {
            if let Some(caps) = IMAGE_RE.captures(s) {
                versions.push(caps[1].to_string());
            }
        }
    });
    versions.sort();
    versions.into_iter().next()
}

fn walk<F: FnMut(&serde_json::Value)>(v: &serde_json::Value, f: &mut F) {
    f(v);
    match v {
        serde_json::Value::Object(m) => {
            for (_, v2) in m {
                walk(v2, f);
            }
        }
        serde_json::Value::Array(a) => {
            for v2 in a {
                walk(v2, f);
            }
        }
        _ => {}
    }
}

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

    #[test]
    fn parses_buildid_from_app_info_output() {
        let sample = r#"
"4754530"
{
    "branches"
    {
        "public"
        {
            "buildid"		"12345678"
        }
    }
}"#;
        assert_eq!(
            parse_buildid_from_app_info(sample),
            Some("12345678".to_string())
        );
    }

    #[test]
    fn parses_buildid_from_acf() {
        let sample = r#"
"AppState"
{
    "appid"		"4754530"
    "buildid"		"99999"
}"#;
        assert_eq!(parse_buildid_from_acf(sample), Some("99999".to_string()));
    }

    #[test]
    fn extracts_live_version_from_image_tag() {
        let bg = serde_json::json!({
            "spec": {
                "serverGroup": {"template": {"spec": {"image": "registry.example/seabass-server:99-0-rev-abc"}}}
            }
        });
        assert_eq!(extract_live_version(&bg), Some("99-0-rev-abc".to_string()));
    }
}
