//! Shared parser, fixture, and JSONL helpers for the scripted peer harness.

#![allow(clippy::missing_errors_doc)]

use std::{
    net::SocketAddr,
    path::{Path, PathBuf},
    time::Duration,
};

use eyre::{Context, OptionExt};
use lanspread_peer::{UnpackFuture, Unpacker};
use serde::Serialize;
use serde_json::{Value, json};

pub const DEFAULT_FIXTURE_VERSION: &str = "20250101";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandEnvelope {
    pub request_id: Option<Value>,
    pub command: CliCommand,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CliCommand {
    Status,
    ListPeers,
    ListGames,
    SetGameDir {
        path: PathBuf,
    },
    Download {
        game_id: String,
        install_after_download: bool,
    },
    Install {
        game_id: String,
    },
    Uninstall {
        game_id: String,
    },
    WaitPeers {
        count: usize,
        timeout: Duration,
    },
    Connect {
        addr: SocketAddr,
    },
    Shutdown,
}

impl CliCommand {
    #[must_use]
    pub fn name(&self) -> &'static str {
        match self {
            Self::Status => "status",
            Self::ListPeers => "list-peers",
            Self::ListGames => "list-games",
            Self::SetGameDir { .. } => "set-game-dir",
            Self::Download { .. } => "download",
            Self::Install { .. } => "install",
            Self::Uninstall { .. } => "uninstall",
            Self::WaitPeers { .. } => "wait-peers",
            Self::Connect { .. } => "connect",
            Self::Shutdown => "shutdown",
        }
    }
}

pub fn parse_command_line(line: &str) -> eyre::Result<CommandEnvelope> {
    let value = serde_json::from_str::<Value>(line).wrap_err("command must be a JSON object")?;
    parse_command_value(&value)
}

pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
    let object = value
        .as_object()
        .ok_or_eyre("command must be a JSON object")?;
    let request_id = object.get("id").cloned();
    let command_name = object
        .get("cmd")
        .or_else(|| object.get("command"))
        .and_then(Value::as_str)
        .ok_or_eyre("command object must include a string cmd")?;
    let command_name = command_name.replace('_', "-");

    let command = match command_name.as_str() {
        "status" => CliCommand::Status,
        "list-peers" => CliCommand::ListPeers,
        "list-games" => CliCommand::ListGames,
        "set-game-dir" => CliCommand::SetGameDir {
            path: PathBuf::from(required_str(object, "path")?),
        },
        "download" => CliCommand::Download {
            game_id: game_id(object)?,
            install_after_download: install_after_download(object)?,
        },
        "install" => CliCommand::Install {
            game_id: game_id(object)?,
        },
        "uninstall" => CliCommand::Uninstall {
            game_id: game_id(object)?,
        },
        "wait-peers" => CliCommand::WaitPeers {
            count: required_u64(object, "count")?
                .try_into()
                .wrap_err("count does not fit in usize")?,
            timeout: Duration::from_millis(required_u64(object, "timeout_ms")?),
        },
        "connect" | "direct-connect" => CliCommand::Connect {
            addr: required_str(object, "addr")?
                .parse()
                .wrap_err("addr must be a socket address like 127.0.0.1:12345")?,
        },
        "shutdown" => CliCommand::Shutdown,
        other => eyre::bail!("unknown command: {other}"),
    };

    Ok(CommandEnvelope {
        request_id,
        command,
    })
}

fn required_str(
    object: &serde_json::Map<String, Value>,
    field: &'static str,
) -> eyre::Result<String> {
    object
        .get(field)
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| eyre::eyre!("missing string field {field}"))
}

fn required_u64(object: &serde_json::Map<String, Value>, field: &'static str) -> eyre::Result<u64> {
    object
        .get(field)
        .and_then(Value::as_u64)
        .ok_or_else(|| eyre::eyre!("missing integer field {field}"))
}

fn game_id(object: &serde_json::Map<String, Value>) -> eyre::Result<String> {
    object
        .get("game_id")
        .or_else(|| object.get("game"))
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_eyre("game commands must include string game_id")
}

fn install_after_download(object: &serde_json::Map<String, Value>) -> eyre::Result<bool> {
    if let Some(value) = object.get("install") {
        return value
            .as_bool()
            .ok_or_eyre("install must be boolean when provided");
    }

    if let Some(value) = object.get("no_install") {
        return value
            .as_bool()
            .map(|no_install| !no_install)
            .ok_or_eyre("no_install must be boolean when provided");
    }

    Ok(true)
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FixtureSeed {
    pub game_id: String,
    pub game_root: PathBuf,
    pub archive: PathBuf,
}

pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result<FixtureSeed> {
    let game_id = fixture_name.to_string();
    let game_root = game_dir.join(&game_id);
    std::fs::create_dir_all(&game_root)?;

    let version_path = game_root.join("version.ini");
    std::fs::write(version_path, DEFAULT_FIXTURE_VERSION.as_bytes())?;

    let archive = game_root.join(format!("{game_id}.eti"));
    std::fs::write(
        &archive,
        format!("fixture archive for {game_id}\n").as_bytes(),
    )?;

    Ok(FixtureSeed {
        game_id,
        game_root,
        archive,
    })
}

pub struct FixtureUnpacker;

impl Unpacker for FixtureUnpacker {
    fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
        Box::pin(async move {
            tokio::fs::create_dir_all(dest).await?;
            let payload = tokio::fs::read(archive).await?;
            tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?;
            Ok(())
        })
    }
}

pub struct ExternalUnrarUnpacker {
    program: PathBuf,
}

impl ExternalUnrarUnpacker {
    #[must_use]
    pub fn new(program: PathBuf) -> Self {
        Self { program }
    }
}

impl Unpacker for ExternalUnrarUnpacker {
    fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
        Box::pin(async move {
            tokio::fs::create_dir_all(dest).await?;
            let status = tokio::process::Command::new(&self.program)
                .arg("x")
                .arg("-o+")
                .arg(archive)
                .arg(dest)
                .status()
                .await?;
            if !status.success() {
                eyre::bail!(
                    "unrar failed for {} with status {status}",
                    archive.display()
                );
            }
            Ok(())
        })
    }
}

pub fn result_line(id: &Option<Value>, command: &str, data: Value) -> eyre::Result<String> {
    output_line(json!({
        "type": "result",
        "id": id,
        "command": command,
        "ok": true,
        "data": data,
    }))
}

pub fn error_line(id: &Option<Value>, command: Option<&str>, error: &str) -> eyre::Result<String> {
    output_line(json!({
        "type": "error",
        "id": id,
        "command": command,
        "error": error,
    }))
}

pub fn event_line(event: &str, data: Value) -> eyre::Result<String> {
    output_line(json!({
        "type": "event",
        "event": event,
        "data": data,
    }))
}

fn output_line(value: Value) -> eyre::Result<String> {
    serde_json::to_string(&value).wrap_err("failed to serialize JSONL output")
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;

    struct TempDir(PathBuf);

    impl TempDir {
        fn new(name: &str) -> Self {
            let path = std::env::temp_dir().join(format!(
                "{name}-{}",
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .expect("system clock should be after unix epoch")
                    .as_nanos()
            ));
            std::fs::create_dir_all(&path).expect("temp dir should be created");
            Self(path)
        }

        fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    #[test]
    fn parses_download_command_with_correlation_id() {
        let parsed = parse_command_line(
            r#"{"id":"req-1","cmd":"download","game_id":"fixture-one","install":false}"#,
        )
        .expect("command should parse");

        assert_eq!(parsed.request_id, Some(json!("req-1")));
        assert_eq!(
            parsed.command,
            CliCommand::Download {
                game_id: "fixture-one".to_string(),
                install_after_download: false,
            }
        );
    }

    #[test]
    fn formats_result_lines() {
        let line = result_line(&Some(json!(7)), "status", json!({"peer_count": 0}))
            .expect("line should serialize");
        let parsed = serde_json::from_str::<Value>(&line).expect("line should be json");
        assert_eq!(parsed["type"], "result");
        assert_eq!(parsed["id"], 7);
        assert_eq!(parsed["data"]["peer_count"], 0);
    }

    #[tokio::test]
    async fn fixture_unpacker_creates_install_payload() {
        let temp = TempDir::new("lanspread-peer-cli-fixture");
        let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed");
        let dest = temp.path().join("staging");
        Arc::new(FixtureUnpacker)
            .unpack(&seed.archive, &dest)
            .await
            .expect("fixture archive should unpack");

        let payload = std::fs::read_to_string(dest.join("fixture-payload.txt"))
            .expect("payload should be written");
        assert!(payload.contains("fixture-one"));
    }
}
