use std::{
    collections::BTreeMap,
    env, fs,
    io::Write,
    path::PathBuf,
    process::{self, Command},
    time::{SystemTime, UNIX_EPOCH},
};

#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

use gotcha_gitea::{Client, Url};
use serde::{Deserialize, Serialize};

type Result<T> = std::result::Result<T, String>;

#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Server {
    pub url: String,
    pub token: String,
}

#[derive(Default, Deserialize, Serialize)]
pub struct Config {
    #[serde(skip)]
    path: PathBuf,
    #[serde(default)]
    pub servers: BTreeMap<String, Server>,
}

pub struct Selection {
    pub name: Option<String>,
    pub url: String,
    pub token: Option<String>,
    pub repository: Option<RepositoryScope>,
}

#[derive(Clone)]
pub struct RepositoryScope {
    pub owner: String,
    pub repository: String,
}

impl RepositoryScope {
    pub fn parse(value: &str) -> Result<Self> {
        let (owner, repository) = value
            .split_once('/')
            .ok_or("repository must be OWNER/REPOSITORY")?;
        if owner.is_empty() || repository.is_empty() || repository.contains('/') {
            return Err("repository must be OWNER/REPOSITORY".into());
        }
        Ok(Self {
            owner: owner.into(),
            repository: repository.into(),
        })
    }
}

impl Config {
    pub fn load() -> Result<Self> {
        let home = env::var_os("HOME").ok_or("HOME is not set")?;
        Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
    }

    fn load_from(path: PathBuf) -> Result<Self> {
        if !path.exists() {
            return Ok(Self {
                path,
                ..Self::default()
            });
        }

        let text = fs::read_to_string(&path)
            .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
        let mut config: Self = serde_yaml::from_str(&text)
            .map_err(|error| format!("invalid {}: {error}", path.display()))?;
        config.path = path;
        for (name, server) in &config.servers {
            validate_name(name)?;
            Client::new(&server.url, Some(&server.token))
                .map_err(|error| format!("invalid server {name}: {error}"))?;
        }
        Ok(config)
    }

    pub fn login(&mut self, name: &str, token: &str) -> Result<()> {
        let url = server_url(name)?;
        Client::new(&url, Some(token)).map_err(|error| error.to_string())?;
        self.servers.insert(
            name.into(),
            Server {
                url,
                token: token.into(),
            },
        );
        self.save()
    }

    pub fn logout(&mut self, name: &str) -> Result<()> {
        if self.servers.remove(name).is_none() {
            return Err(format!("server profile {name:?} does not exist"));
        }
        self.save()
    }

    pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
        if name.is_some() && url.is_some() {
            return Err("use either --server or --url, not both".into());
        }
        let remotes = git_remotes().unwrap_or_default();

        if let Some(name) = name {
            let server = self
                .servers
                .get(name)
                .ok_or_else(|| format!("server profile {name:?} does not exist"))?;
            return Ok(selection(Some(name), server, &remotes));
        }

        if let Some(url) = url {
            let matches: Vec<_> = self
                .servers
                .iter()
                .filter(|(_, server)| same_instance(&server.url, url))
                .collect();
            return match matches.as_slice() {
                [] => Ok(selection(
                    None,
                    &Server {
                        url: url.into(),
                        token: String::new(),
                    },
                    &remotes,
                )),
                [(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
                _ => Err("multiple profiles use that URL; select one with --server".into()),
            };
        }

        let mut matches = BTreeMap::new();
        for (name, server) in &self.servers {
            if let Some(scope) = remotes
                .iter()
                .find_map(|remote| repository_scope(&server.url, remote))
            {
                matches.insert(name, (server, scope));
            }
        }

        match matches.into_iter().collect::<Vec<_>>().as_slice() {
            [] if self.servers.is_empty() => {
                Err("no servers configured; run `gotcha auth login SERVER`".into())
            }
            [] => Err("no configured server matches this Git repository; use --server NAME".into()),
            [(name, (server, scope))] => Ok(Selection {
                name: Some((*name).clone()),
                url: server.url.clone(),
                token: Some(server.token.clone()),
                repository: Some(scope.clone()),
            }),
            _ => Err("multiple server profiles match this Git repository; use --server".into()),
        }
    }

    fn save(&self) -> Result<()> {
        let parent = self
            .path
            .parent()
            .ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
        fs::create_dir_all(parent)
            .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;

        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|error| error.to_string())?
            .as_nanos();
        let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
        let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
        let result = (|| -> std::io::Result<()> {
            let mut options = fs::OpenOptions::new();
            options.write(true).create_new(true);
            #[cfg(unix)]
            options.mode(0o600);
            let mut file = options.open(&temporary)?;
            file.write_all(text.as_bytes())?;
            file.sync_all()?;
            fs::rename(&temporary, &self.path)?;
            #[cfg(unix)]
            fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
            Ok(())
        })();
        if result.is_err() {
            let _ = fs::remove_file(&temporary);
        }
        result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
    }
}

fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection {
    Selection {
        name: name.map(str::to_owned),
        url: server.url.clone(),
        token: (!server.token.is_empty()).then(|| server.token.clone()),
        repository: remotes
            .iter()
            .find_map(|remote| repository_scope(&server.url, remote)),
    }
}

fn validate_name(name: &str) -> Result<()> {
    server_url(name)?;
    Ok(())
}

pub fn server_url(name: &str) -> Result<String> {
    if name.is_empty()
        || name.contains('/')
        || name.contains('@')
        || name.chars().any(char::is_whitespace)
    {
        return Err("server name must be a hostname, optionally followed by a port".into());
    }
    let url = format!("https://{name}");
    let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
    if parsed.host_str().is_none() || parsed.path() != "/" {
        return Err("server name must be a hostname, optionally followed by a port".into());
    }
    if parsed.query().is_some() || parsed.fragment().is_some() {
        return Err("server name must be a hostname, optionally followed by a port".into());
    }
    Ok(url)
}

fn same_instance(left: &str, right: &str) -> bool {
    let api_url = |url| {
        Client::new(url, None)
            .ok()
            .map(|client| client.api_url().clone())
    };
    api_url(left) == api_url(right)
}

fn git_remotes() -> Result<Vec<String>> {
    let output = Command::new("git")
        .args(["config", "--get-regexp", r"^remote\..*\.url$"])
        .output()
        .map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
    if !output.status.success() {
        return Ok(Vec::new());
    }

    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter_map(|line| line.split_once(char::is_whitespace))
        .map(|(_, url)| url.trim().to_owned())
        .collect())
}

fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryScope> {
    let server = Url::parse(server_url).ok()?;
    let server_host = server.host_str()?;
    let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
        (
            url.host_str()?.to_owned(),
            url.path().trim_matches('/').to_owned(),
            matches!(url.scheme(), "http" | "https"),
        )
    } else {
        let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
        let (host, path) = remote.split_once(':')?;
        (host.into(), path.trim_matches('/').into(), false)
    };
    if !server_host.eq_ignore_ascii_case(&remote_host) {
        return None;
    }

    let prefix = server.path().trim_matches('/');
    if is_http && !prefix.is_empty() {
        remote_path = remote_path
            .strip_prefix(prefix)?
            .strip_prefix('/')?
            .to_owned();
    }
    let parts: Vec<_> = remote_path
        .split('/')
        .filter(|part| !part.is_empty())
        .collect();
    let [.., owner, repository] = parts.as_slice() else {
        return None;
    };
    let repository = repository.strip_suffix(".git").unwrap_or(repository);
    (!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope {
        owner: (*owner).into(),
        repository: repository.into(),
    })
}

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

    #[test]
    fn config_round_trip_and_remote_scope() {
        let directory = env::temp_dir().join(format!(
            "gotcha-config-test-{}-{}",
            process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let path = directory.join("config");
        let mut config = Config {
            path: path.clone(),
            ..Config::default()
        };
        assert_eq!(
            config.select(None, None).err().unwrap(),
            "no servers configured; run `gotcha auth login SERVER`"
        );
        config.login("code.example", "secret").unwrap();

        let loaded = Config::load_from(path).unwrap();
        assert_eq!(loaded.servers["code.example"].token, "secret");
        assert!(
            fs::read_to_string(&loaded.path)
                .unwrap()
                .contains("token: secret")
        );
        let scope = repository_scope(
            "https://code.example/gitea",
            "https://code.example/gitea/alice/project.git",
        )
        .unwrap();
        assert_eq!(
            (scope.owner.as_str(), scope.repository.as_str()),
            ("alice", "project")
        );
        #[cfg(unix)]
        assert_eq!(
            fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
            0o600
        );

        fs::remove_dir_all(directory).unwrap();
    }
}
