use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

const GIT_TIMEOUT: Duration = Duration::from_secs(120);

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginSpec {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginLock {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    pub commit: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Lockfile {
    plugins: BTreeMap<String, PluginLock>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginStatus {
    pub name: String,
    pub referenced: bool,
    pub installed: bool,
    pub url: Option<String>,
    pub branch: Option<String>,
    pub tag: Option<String>,
    pub rev: Option<String>,
    pub path: Option<String>,
    pub current: Option<String>,
    pub locked: Option<String>,
    pub dirty: Option<bool>,
    pub update_available: Option<bool>,
}

pub fn plugin_root() -> PathBuf {
    data_dir().join("plugins")
}

fn data_dir() -> PathBuf {
    std::env::var_os("TPANE_DATA_DIR")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("XDG_DATA_HOME").map(|home| PathBuf::from(home).join("tpane")))
        .or_else(|| {
            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share/tpane"))
        })
        .unwrap_or_else(|| PathBuf::from(".local/share/tpane"))
}

fn config_dir() -> PathBuf {
    if let Some(path) = std::env::var_os("TPANE_CONFIG_DIR") {
        return PathBuf::from(path);
    }

    let home = config_home();
    let tmux = home.join("tmux/tpane");
    let legacy = home.join("tpane");
    if tmux.exists() || !legacy.exists() {
        tmux
    } else {
        legacy
    }
}

fn config_home() -> PathBuf {
    std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
        .unwrap_or_else(|| PathBuf::from(".config"))
}

pub fn lockfile_path() -> PathBuf {
    config_dir().join("tpane-lock.json")
}

pub fn plugin_dir(name: &str) -> PathBuf {
    plugin_root().join(name)
}

fn metadata_path(name: &str) -> PathBuf {
    plugin_dir(name).join(".tpane-plugin.json")
}

fn clone_args(url: &str, dest: &Path, spec: &PluginSpec) -> Vec<String> {
    let mut args = vec!["clone".to_string()];
    if let Some(branch) = &spec.branch {
        args.extend([
            "--branch".to_string(),
            branch.clone(),
            "--single-branch".to_string(),
        ]);
    }
    if spec.path.is_some() {
        args.extend(["--filter=blob:none".to_string(), "--sparse".to_string()]);
    }
    args.extend([
        "--".to_string(),
        url.to_string(),
        dest.display().to_string(),
    ]);
    args
}

fn checkout_args(rev: &str) -> Vec<String> {
    vec![
        "checkout".to_string(),
        "--detach".to_string(),
        rev.to_string(),
    ]
}

pub fn infer_name(url: &str) -> String {
    url.trim_end_matches('/')
        .rsplit(['/', ':'])
        .next()
        .unwrap_or(url)
        .trim_end_matches(".git")
        .to_string()
}

pub fn validate_plugin_name(name: &str) -> Result<()> {
    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') {
        bail!("invalid plugin name: {name}");
    }
    Ok(())
}

fn validate_ref(name: &str, value: &str) -> Result<()> {
    if value.is_empty() || value.starts_with('-') {
        bail!("invalid plugin {name}: {value}");
    }
    Ok(())
}

pub fn validate_spec(spec: &PluginSpec) -> Result<()> {
    if let Some(url) = &spec.url
        && url.starts_with('-')
    {
        bail!("invalid plugin url: {url}");
    }
    let refs = [
        spec.branch.is_some(),
        spec.tag.is_some(),
        spec.rev.is_some(),
    ]
    .into_iter()
    .filter(|set| *set)
    .count();
    if refs > 1 {
        bail!("plugin spec can set only one of branch, tag, or rev");
    }
    if let Some(branch) = &spec.branch {
        validate_ref("branch", branch)?;
    }
    if let Some(tag) = &spec.tag {
        validate_ref("tag", tag)?;
    }
    if let Some(rev) = &spec.rev {
        validate_ref("rev", rev)?;
    }
    if let Some(path) = &spec.path {
        validate_plugin_path(path)?;
    }
    Ok(())
}

fn validate_plugin_path(path: &str) -> Result<()> {
    let raw = path;
    let path = Path::new(path);
    if raw.is_empty()
        || raw.starts_with('-')
        || path.is_absolute()
        || path
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        bail!("invalid plugin path: {}", path.display());
    }
    Ok(())
}

pub fn ensure(name: &str, spec: &PluginSpec) -> Result<PathBuf> {
    validate_plugin_name(name)?;
    validate_spec(spec)?;
    let dir = plugin_dir(name);
    if dir.exists() {
        assert_compatible(name, spec)?;
        return Ok(dir);
    }
    let Some(url) = spec.url.as_deref() else {
        bail!("plugin {name} is not installed");
    };
    add(url, Some(name), spec.clone())
}

pub fn add(url: &str, name: Option<&str>, mut spec: PluginSpec) -> Result<PathBuf> {
    if url.starts_with('-') {
        bail!("invalid plugin url: {url}");
    }
    if let Some(existing_url) = &spec.url
        && existing_url != url
    {
        bail!("plugin url does not match spec url");
    }
    spec.url = Some(url.to_string());
    validate_spec(&spec)?;
    let name = name.map(str::to_string).unwrap_or_else(|| infer_name(url));
    validate_plugin_name(&name)?;
    let dest = plugin_dir(&name);
    if dest.exists() {
        bail!("plugin already exists: {name}");
    }
    if let Some(parent) = dest.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    git_status(
        Command::new("git").args(clone_args(url, &dest, &spec)),
        &format!("git clone failed for {url}"),
    )?;

    if let Some(commit) = locked_commit(&name, &spec)? {
        checkout(&dest, &commit)?;
    } else if let Some(tag) = &spec.tag {
        checkout(&dest, tag)?;
    } else if let Some(rev) = &spec.rev {
        checkout(&dest, rev)?;
    }
    configure_sparse_checkout(&dest, &spec)?;

    write_metadata(&name, &spec)?;
    write_lock_entry(&name, &spec, &current_commit(&dest)?)?;
    Ok(dest)
}

pub fn list() -> Result<Vec<String>> {
    let root = plugin_root();
    let Ok(entries) = fs::read_dir(&root) else {
        return Ok(Vec::new());
    };
    let mut names = entries
        .filter_map(std::result::Result::ok)
        .filter_map(|entry| {
            entry
                .file_type()
                .ok()
                .filter(|file_type| file_type.is_dir())
                .and_then(|_| entry.file_name().into_string().ok())
        })
        .collect::<Vec<_>>();
    names.sort();
    Ok(names)
}

pub fn remove(name: &str) -> Result<()> {
    validate_plugin_name(name)?;
    let dir = plugin_dir(name);
    if !dir.exists() {
        bail!("unknown plugin: {name}");
    }
    fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
    remove_lock_entry(name)
}

pub fn clean(keep: &HashSet<String>) -> Result<Vec<String>> {
    let mut removed = Vec::new();
    for name in list()? {
        if !keep.contains(&name) {
            remove(&name)?;
            removed.push(name);
        }
    }
    Ok(removed)
}

pub fn sync(specs: &HashMap<String, PluginSpec>) -> Result<Vec<String>> {
    let mut synced = Vec::new();
    let mut names = specs.keys().cloned().collect::<Vec<_>>();
    names.sort();
    for name in names {
        let spec = specs.get(&name).expect("name came from specs");
        validate_plugin_name(&name)?;
        validate_spec(spec)?;
        if plugin_dir(&name).exists() {
            update_one_with_spec(&name, spec)?;
            synced.push(name);
        } else if spec.url.is_some() {
            ensure(&name, spec)?;
            synced.push(name);
        }
    }
    Ok(synced)
}

pub fn update(name: Option<&str>) -> Result<Vec<String>> {
    let names = match name {
        Some(name) => {
            validate_plugin_name(name)?;
            vec![name.to_string()]
        }
        None => list()?,
    };
    let mut updated = Vec::new();
    for name in names {
        update_one(&name)?;
        updated.push(name);
    }
    Ok(updated)
}

pub fn status(specs: &HashMap<String, PluginSpec>) -> Result<Vec<PluginStatus>> {
    let mut names = list()?.into_iter().collect::<HashSet<_>>();
    names.extend(specs.keys().cloned());
    let mut names = names.into_iter().collect::<Vec<_>>();
    names.sort();

    names
        .into_iter()
        .map(|name| {
            let referenced = specs.contains_key(&name);
            let installed = plugin_dir(&name).exists();
            let spec = specs
                .get(&name)
                .cloned()
                .or_else(|| read_metadata(&name).ok())
                .unwrap_or_default();
            let lock = read_lockfile()?.plugins.remove(&name);
            let current = installed
                .then(|| current_commit(&plugin_dir(&name)))
                .transpose()?;
            let dirty = installed.then(|| dirty(&plugin_dir(&name))).transpose()?;
            let update_available = if installed {
                update_available(&plugin_dir(&name), &spec, current.as_deref())?
            } else {
                None
            };
            Ok(PluginStatus {
                name,
                referenced,
                installed,
                url: spec.url,
                branch: spec.branch,
                tag: spec.tag,
                rev: spec.rev,
                path: spec.path,
                current,
                locked: lock.map(|lock| lock.commit),
                dirty,
                update_available,
            })
        })
        .collect()
}

fn update_one(name: &str) -> Result<()> {
    let spec = read_metadata(name)?;
    update_one_with_spec(name, &spec)
}

fn update_one_with_spec(name: &str, spec: &PluginSpec) -> Result<()> {
    let dir = plugin_dir(name);
    if !dir.exists() {
        bail!("unknown plugin: {name}");
    }
    if let Some(metadata) = read_metadata(name).ok()
        && spec.url.is_some()
        && metadata.url != spec.url
    {
        bail!("plugin {name} is installed with a different url");
    }

    if let Some(branch) = &spec.branch {
        git(&dir, &["fetch", "origin", branch])?;
        git(
            &dir,
            &["checkout", "-B", branch, &format!("origin/{branch}")],
        )?;
    } else if let Some(tag) = &spec.tag {
        git(&dir, &["fetch", "--tags", "origin"])?;
        checkout(&dir, tag)?;
    } else if let Some(rev) = &spec.rev {
        git(&dir, &["fetch", "origin"])?;
        checkout(&dir, rev)?;
    } else {
        git(&dir, &["pull", "--ff-only"])?;
    }
    configure_sparse_checkout(&dir, spec)?;

    write_metadata(name, spec)?;
    write_lock_entry(name, spec, &current_commit(&dir)?)
}

fn checkout(dir: &Path, rev: &str) -> Result<()> {
    validate_ref("rev", rev)?;
    git_status(
        Command::new("git")
            .current_dir(dir)
            .args(checkout_args(rev)),
        &format!("git checkout failed for {rev}"),
    )
}

fn configure_sparse_checkout(dir: &Path, spec: &PluginSpec) -> Result<()> {
    if let Some(path) = &spec.path {
        git(dir, &["sparse-checkout", "set", "--", path])?;
    }
    Ok(())
}

fn git(dir: &Path, args: &[&str]) -> Result<()> {
    git_status(
        Command::new("git").current_dir(dir).args(args),
        &format!("git command failed in {}", dir.display()),
    )
}

fn git_status(command: &mut Command, failure: &str) -> Result<()> {
    command
        .env("GIT_TERMINAL_PROMPT", "0")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    if std::env::var_os("GIT_SSH_COMMAND").is_none() {
        command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
    }
    let mut child = command.spawn().with_context(|| failure.to_string())?;
    let deadline = Instant::now() + GIT_TIMEOUT;
    loop {
        if let Some(status) = child.try_wait()? {
            if status.success() {
                return Ok(());
            }
            bail!(failure.to_string());
        }
        if Instant::now() >= deadline {
            let _ = child.kill();
            let _ = child.wait();
            bail!(
                "{failure}: timed out after {} seconds",
                GIT_TIMEOUT.as_secs()
            );
        }
        thread::sleep(Duration::from_millis(100));
    }
}

fn git_output(dir: &Path, args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_TERMINAL_PROMPT", "0")
        .output()
        .with_context(|| format!("failed to run git in {}", dir.display()))?;
    if !output.status.success() {
        bail!("git command failed in {}", dir.display());
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn current_commit(dir: &Path) -> Result<String> {
    git_output(dir, &["rev-parse", "HEAD"])
}

fn dirty(dir: &Path) -> Result<bool> {
    Ok(!git_output(dir, &["status", "--porcelain"])?.is_empty())
}

fn update_available(dir: &Path, spec: &PluginSpec, current: Option<&str>) -> Result<Option<bool>> {
    let Some(branch) = &spec.branch else {
        return Ok(None);
    };
    let remote = git_output(dir, &["rev-parse", "--verify", &format!("origin/{branch}")])?;
    Ok(current.map(|current| current != remote))
}

pub fn entrypoint(name: &str, spec: &PluginSpec) -> Result<PathBuf> {
    validate_plugin_name(name)?;
    validate_spec(spec)?;
    let installed = plugin_dir(name);
    let base = if let Some(path) = &spec.path {
        installed.join(path)
    } else if metadata_path(name).exists() {
        match read_metadata(name)?.path {
            Some(path) => installed.join(path),
            None => installed,
        }
    } else {
        installed
    };
    Ok(base.join("init.lua"))
}

pub fn assert_compatible(name: &str, requested: &PluginSpec) -> Result<()> {
    if requested.url.is_none()
        && requested.branch.is_none()
        && requested.tag.is_none()
        && requested.rev.is_none()
        && requested.path.is_none()
    {
        return Ok(());
    }
    let Ok(installed) = read_metadata(name) else {
        bail!("plugin {name} is not installed with requested ref");
    };
    if requested.url.is_some() && requested.url != installed.url {
        bail!("plugin {name} is not installed with requested url");
    }
    Ok(())
}

fn locked_commit(name: &str, spec: &PluginSpec) -> Result<Option<String>> {
    let Some(lock) = read_lockfile()?.plugins.remove(name) else {
        return Ok(None);
    };
    if lock_matches_spec(&lock, spec) {
        validate_ref("locked commit", &lock.commit)?;
        Ok(Some(lock.commit))
    } else {
        Ok(None)
    }
}

fn lock_matches_spec(lock: &PluginLock, spec: &PluginSpec) -> bool {
    (spec.url.is_none() || spec.url == lock.url)
        && (spec.branch.is_none() || spec.branch == lock.branch)
        && (spec.tag.is_none() || spec.tag == lock.tag)
        && (spec.rev.is_none() || spec.rev == lock.rev)
        && (spec.path.is_none() || spec.path == lock.path)
}

fn read_metadata(name: &str) -> Result<PluginSpec> {
    let path = metadata_path(name);
    let source =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    serde_json::from_str(&source).with_context(|| format!("failed to parse {}", path.display()))
}

fn write_metadata(name: &str, spec: &PluginSpec) -> Result<()> {
    let path = metadata_path(name);
    let source = serde_json::to_string_pretty(spec)?;
    fs::write(&path, source).with_context(|| format!("failed to write {}", path.display()))
}

fn read_lockfile() -> Result<Lockfile> {
    let path = lockfile_path();
    if !path.exists() {
        return Ok(Lockfile::default());
    }
    let source =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    serde_json::from_str(&source).with_context(|| format!("failed to parse {}", path.display()))
}

fn write_lockfile(lockfile: &Lockfile) -> Result<()> {
    let path = lockfile_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let source = serde_json::to_string_pretty(lockfile)?;
    fs::write(&path, format!("{source}\n"))
        .with_context(|| format!("failed to write {}", path.display()))
}

fn write_lock_entry(name: &str, spec: &PluginSpec, commit: &str) -> Result<()> {
    let mut lockfile = read_lockfile()?;
    lockfile.plugins.insert(
        name.to_string(),
        PluginLock {
            url: spec.url.clone(),
            branch: spec.branch.clone(),
            tag: spec.tag.clone(),
            rev: spec.rev.clone(),
            path: spec.path.clone(),
            commit: commit.to_string(),
        },
    );
    write_lockfile(&lockfile)
}

fn remove_lock_entry(name: &str) -> Result<()> {
    let mut lockfile = read_lockfile()?;
    if lockfile.plugins.remove(name).is_some() {
        write_lockfile(&lockfile)?;
    }
    Ok(())
}

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

    #[test]
    fn plugin_paths_live_under_data_plugins() {
        let path = plugin_dir("foo");
        assert!(path.ends_with("tpane/plugins/foo"));
    }

    #[test]
    fn clone_args_match_git_clone_shape() {
        assert_eq!(
            clone_args(
                "https://example.test/me/foo.git",
                Path::new("/tmp/foo"),
                &PluginSpec::default()
            ),
            vec!["clone", "--", "https://example.test/me/foo.git", "/tmp/foo"]
        );
    }

    #[test]
    fn clone_args_include_tag_only_as_metadata() {
        assert_eq!(
            clone_args(
                "https://example.test/me/foo.git",
                Path::new("/tmp/foo"),
                &PluginSpec {
                    tag: Some("v1.0.0".to_string()),
                    ..PluginSpec::default()
                },
            ),
            vec!["clone", "--", "https://example.test/me/foo.git", "/tmp/foo"]
        );
    }

    #[test]
    fn clone_args_include_branch_before_separator() {
        assert_eq!(
            clone_args(
                "https://example.test/me/foo.git",
                Path::new("/tmp/foo"),
                &PluginSpec {
                    branch: Some("main".to_string()),
                    ..PluginSpec::default()
                },
            ),
            vec![
                "clone",
                "--branch",
                "main",
                "--single-branch",
                "--",
                "https://example.test/me/foo.git",
                "/tmp/foo"
            ]
        );
    }

    #[test]
    fn clone_args_use_sparse_clone_for_plugin_paths() {
        assert_eq!(
            clone_args(
                "https://example.test/me/foo.git",
                Path::new("/tmp/foo"),
                &PluginSpec {
                    path: Some("plugins/foo".to_string()),
                    ..PluginSpec::default()
                },
            ),
            vec![
                "clone",
                "--filter=blob:none",
                "--sparse",
                "--",
                "https://example.test/me/foo.git",
                "/tmp/foo"
            ]
        );
    }

    #[test]
    fn plugin_names_reject_paths() {
        assert!(validate_plugin_name("foo").is_ok());
        assert!(validate_plugin_name("").is_err());
        assert!(validate_plugin_name(".").is_err());
        assert!(validate_plugin_name("..").is_err());
        assert!(validate_plugin_name("../foo").is_err());
        assert!(validate_plugin_name("foo\\bar").is_err());
    }

    #[test]
    fn plugin_specs_reject_multiple_refs_and_unsafe_paths() {
        assert!(
            validate_spec(&PluginSpec {
                branch: Some("main".to_string()),
                tag: Some("v1".to_string()),
                ..PluginSpec::default()
            })
            .is_err()
        );
        assert!(
            validate_spec(&PluginSpec {
                path: Some("../foo".to_string()),
                ..PluginSpec::default()
            })
            .is_err()
        );
    }

    #[test]
    fn lock_matches_requested_spec_fields() {
        let lock = PluginLock {
            url: Some("https://example.test/foo.git".to_string()),
            branch: Some("main".to_string()),
            path: Some("plugins/foo".to_string()),
            commit: "abc123".to_string(),
            ..PluginLock::default()
        };
        assert!(lock_matches_spec(
            &lock,
            &PluginSpec {
                url: Some("https://example.test/foo.git".to_string()),
                branch: Some("main".to_string()),
                path: Some("plugins/foo".to_string()),
                ..PluginSpec::default()
            },
        ));
        assert!(!lock_matches_spec(
            &lock,
            &PluginSpec {
                branch: Some("dev".to_string()),
                ..PluginSpec::default()
            },
        ));
    }

    #[test]
    fn infer_name_from_common_git_urls() {
        assert_eq!(infer_name("https://github.com/me/foo.git"), "foo");
        assert_eq!(infer_name("git@github.com:me/bar.git"), "bar");
        assert_eq!(infer_name("https://github.com/me/baz/"), "baz");
    }
}
