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

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

use serde::{Deserialize, Serialize};

use crate::{Client, Provider, RepositoryId, Url};

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

#[derive(Clone, Default, Deserialize, Serialize)]
pub struct ServerProfile {
    pub url: String,
    pub token: String,
    #[serde(default)]
    pub provider: Provider,
}

#[derive(Clone, Deserialize, Serialize)]
pub struct TuiPreferences {
    #[serde(default = "default_refresh_seconds")]
    pub refresh_seconds: u64,
    #[serde(default)]
    pub favorites: BTreeSet<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_server: Option<String>,
}

impl Default for TuiPreferences {
    fn default() -> Self {
        Self {
            refresh_seconds: default_refresh_seconds(),
            favorites: BTreeSet::new(),
            last_server: None,
        }
    }
}

const fn default_refresh_seconds() -> u64 {
    5
}

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

pub struct Selection {
    pub name: Option<String>,
    pub url: String,
    pub token: Option<String>,
    pub provider: Provider,
    pub repository: Option<RepositoryId>,
}

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::with_provider(&server.url, Some(&server.token), server.provider)
                .map_err(|error| format!("invalid server {name}: {error}"))?;
        }
        Ok(config)
    }

    pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
        let url = server_url(name)?;
        self.save_server(name, &url, token, provider)
    }

    pub fn save_server(
        &mut self,
        name: &str,
        url: &str,
        token: &str,
        provider: Provider,
    ) -> Result<()> {
        validate_name(name)?;
        Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
        let mut updated = self.clone();
        updated.servers.insert(
            name.into(),
            ServerProfile {
                url: url.trim_end_matches('/').into(),
                token: token.into(),
                provider,
            },
        );
        updated.save()?;
        *self = updated;
        Ok(())
    }

    pub fn replace_server(
        &mut self,
        original_name: &str,
        name: &str,
        url: &str,
        token: &str,
        provider: Provider,
    ) -> Result<()> {
        if !self.servers.contains_key(original_name) {
            return Err(format!("server profile {original_name:?} does not exist"));
        }
        validate_name(name)?;
        Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
        let mut updated = self.clone();
        let old_url = updated
            .servers
            .get(original_name)
            .map(|server| server.url.clone())
            .expect("profile existence checked above");
        let new_url = url.trim_end_matches('/');
        updated.servers.remove(original_name);
        updated.servers.insert(
            name.into(),
            ServerProfile {
                url: new_url.into(),
                token: token.into(),
                provider,
            },
        );
        if old_url != new_url {
            migrate_favorites(&mut updated.tui.favorites, &old_url, new_url);
        }
        if updated.tui.last_server.as_deref() == Some(original_name) {
            updated.tui.last_server = Some(name.into());
        }
        updated.save()?;
        *self = updated;
        Ok(())
    }

    pub fn logout(&mut self, name: &str) -> Result<()> {
        if !self.servers.contains_key(name) {
            return Err(format!("server profile {name:?} does not exist"));
        }
        let mut updated = self.clone();
        let removed_url = updated.servers[name].url.clone();
        updated.servers.remove(name);
        if !updated
            .servers
            .values()
            .any(|server| server.url == removed_url)
        {
            remove_favorites(&mut updated.tui.favorites, &removed_url);
        }
        if updated.tui.last_server.as_deref() == Some(name) {
            updated.tui.last_server = updated.servers.keys().next().cloned();
        }
        updated.save()?;
        *self = updated;
        Ok(())
    }

    pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> {
        let mut updated = self.clone();
        updated.tui.refresh_seconds = seconds;
        updated.save()?;
        *self = updated;
        Ok(())
    }

    pub fn set_tui_last_server(&mut self, name: Option<&str>) -> Result<()> {
        if let Some(name) = name
            && !self.servers.contains_key(name)
        {
            return Err(format!("server profile {name:?} does not exist"));
        }
        if self.tui.last_server.as_deref() == name {
            return Ok(());
        }
        let mut updated = self.clone();
        updated.tui.last_server = name.map(str::to_owned);
        updated.save()?;
        *self = updated;
        Ok(())
    }

    pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool {
        self.tui
            .favorites
            .contains(&favorite_key(pane, server_url, repository))
    }

    pub fn toggle_tui_favorite(
        &mut self,
        pane: &str,
        server_url: &str,
        repository: &RepositoryId,
    ) -> Result<()> {
        let key = favorite_key(pane, server_url, repository);
        let mut updated = self.clone();
        if !updated.tui.favorites.remove(&key) {
            updated.tui.favorites.insert(key);
        }
        updated.save()?;
        *self = updated;
        Ok(())
    }

    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,
                    &ServerProfile {
                        url: url.into(),
                        token: String::new(),
                        provider: Provider::Gitea,
                    },
                    &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()),
                provider: server.provider,
                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 favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String {
    format!(
        "{pane}|{}|{}/{}",
        server_url.trim_end_matches('/'),
        repository.owner,
        repository.repository
    )
}

fn migrate_favorites(favorites: &mut BTreeSet<String>, old_url: &str, new_url: &str) {
    let old = format!("|{}|", old_url.trim_end_matches('/'));
    let new = format!("|{}|", new_url.trim_end_matches('/'));
    let migrations: Vec<_> = favorites
        .iter()
        .filter(|key| key.contains(&old))
        .map(|key| (key.clone(), key.replacen(&old, &new, 1)))
        .collect();
    for (old, new) in migrations {
        favorites.remove(&old);
        favorites.insert(new);
    }
}

fn remove_favorites(favorites: &mut BTreeSet<String>, server_url: &str) {
    let server = format!("|{}|", server_url.trim_end_matches('/'));
    favorites.retain(|key| !key.contains(&server));
}

fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection {
    Selection {
        name: name.map(str::to_owned),
        url: server.url.clone(),
        token: (!server.token.is_empty()).then(|| server.token.clone()),
        provider: server.provider,
        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<RepositoryId> {
    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(|| RepositoryId {
        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.tui.refresh_seconds, 5);
        assert_eq!(
            config.select(None, None).err().unwrap(),
            "no servers configured; run `gotcha auth login SERVER`"
        );
        config
            .login("code.example", "secret", Provider::Forgejo)
            .unwrap();
        config.set_tui_refresh_seconds(9).unwrap();
        config.set_tui_last_server(Some("code.example")).unwrap();

        let mut loaded = Config::load_from(path).unwrap();
        assert_eq!(loaded.tui.refresh_seconds, 9);
        assert_eq!(loaded.tui.last_server.as_deref(), Some("code.example"));
        assert_eq!(loaded.servers["code.example"].token, "secret");
        assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
        assert_eq!(
            loaded.select(Some("code.example"), None).unwrap().provider,
            Provider::Forgejo
        );
        let repository = RepositoryId {
            owner: "alice".into(),
            repository: "project".into(),
        };
        loaded
            .toggle_tui_favorite("issues", "https://code.example", &repository)
            .unwrap();
        assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository));
        loaded
            .replace_server(
                "code.example",
                "new.example",
                "https://new.example",
                "new-secret",
                Provider::Gitea,
            )
            .unwrap();
        assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository));
        assert!(!loaded.servers.contains_key("code.example"));
        assert_eq!(loaded.servers["new.example"].token, "new-secret");
        assert_eq!(loaded.tui.last_server.as_deref(), Some("new.example"));
        assert!(
            fs::read_to_string(&loaded.path)
                .unwrap()
                .contains("provider: gitea")
        );
        let legacy: ServerProfile =
            serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
        assert_eq!(legacy.provider, Provider::Gitea);
        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
        );
        loaded.logout("new.example").unwrap();
        assert_eq!(loaded.tui.last_server, None);

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