Documentation
use std::{collections::HashMap, path::Path, sync::Arc};

use anyhow::{Result, bail};
use async_trait::async_trait;
use futures::future::try_join_all;
use itertools::Itertools;
use log::debug;
use rustc_hash::FxHashMap;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use url::Url;

use crate::Hydration;

use super::{Provider, add_url};

static SEP: &str = "'Km5Ge8AbNc+QSBauOIN0jg'";

#[derive(Default)]
pub struct OnePassword {
    urls: FxHashMap<Url, String>,
}

impl OnePassword {
    pub fn new() -> Self {
        Default::default()
    }
}

#[async_trait]
impl Provider for OnePassword {
    fn add(&mut self, value: String) -> Result<()> {
        match Url::parse(&value) {
            Ok(url) if url.scheme() == "op" => {
                if url.path().contains('+') {
                    bail!(
                        "1Password secret references cannot contain '+' in any path segment. \
                         Use the item or field UUID instead (found with: op item get 'NAME' --format json)."
                    );
                }
                add_url(&mut self.urls, value, "op")
            }
            _ => bail!("Not an op scheme"),
        }
    }

    fn has_work(&self) -> bool {
        !self.urls.is_empty()
    }

    async fn resolve(&self, _: &Path, extra_env: &HashMap<String, String>) -> Result<Hydration> {
        let extra_env = Arc::new(extra_env.clone());
        let fetches = self
            .urls
            .iter()
            .into_group_map_by(|(url, _)| url.host().expect("Missing host"))
            .into_iter()
            .map(|(host, group)| {
                let vars = group
                    .into_iter()
                    .enumerate()
                    .map(|(idx, (_, value))| (idx.to_string(), value.clone()))
                    .collect::<HashMap<_, _>>();

                let host = host.clone();
                let extra_env = Arc::clone(&extra_env);
                async move {
                    if vars.is_empty() {
                        return Ok(Hydration::default());
                    }

                    let input = &vars.values()
                        .map(|v| {
                            let s = v.replace(&format!("{host}/"), "");
                            urlencoding::decode(&s).expect("invalid percent-encoding in op:// URL").into_owned()
                        })
                        .join(SEP);
                    let cmd = &["op", "inject", "--account", &host.to_string()];
                    debug!("Lade run: {}", cmd.join(" "));

                    let mut process = Command::new(cmd[0])
                        .args(&cmd[1..])
                        .envs(extra_env.iter())
                        .stdout(Stdio::piped())
                        .stderr(Stdio::piped())
                        .stdin(Stdio::piped())
                        .spawn()?;

                    debug!("stdin: {:?}", input);

                    let mut stdin = process.stdin.take().expect("Failed to open stdin");
                    if let Err(e) = stdin.write_all(input.as_bytes()).await
                        && e.kind() != std::io::ErrorKind::BrokenPipe
                    {
                        bail!("1Password error: {e}");
                    }
                    drop(stdin);

                    let child = match process.wait_with_output().await {
                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                            bail!("1Password CLI not found. Make sure the binary is in your PATH or install it from https://1password.com/downloads/command-line/.")
                        },
                        Err(e) => bail!("1Password error: {e}"),
                        Ok(child) => child,
                    };

                    let output = String::from_utf8_lossy(&child.stdout).trim().replace('\n', "\\n");
                    let errors = String::from_utf8_lossy(&child.stderr);

                    if errors.contains("[ERROR]") {
                        bail!("1Password error: {errors}")
                    }

                    debug!("stdout: {:?}", output);
                    debug!("stderr: {:?}", errors);
                    let loaded = output.split(SEP).collect::<Vec<_>>();

                    if loaded.len() != vars.len() {
                        bail!("1Password error: {errors}")
                    }

                    let hydration = vars
                        .iter().zip_eq(loaded)
                        .map(|((_, key), value)| (key.clone(), value.to_string()))
                        .collect::<Hydration>();

                    debug!("hydration: {:?}", hydration);
                    Ok(hydration)
                }
            })
            .collect::<Vec<_>>();

        Ok(try_join_all(fetches)
            .await?
            .into_iter()
            .flatten()
            .collect::<Hydration>())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::fake_cli;
    use std::collections::HashMap;
    use std::path::Path;
    use tempfile::tempdir;

    #[test]
    fn test_add_valid_op_scheme() {
        let mut p = OnePassword::new();
        assert!(
            p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
                .is_ok()
        );
    }

    #[test]
    fn test_add_rejects_wrong_scheme() {
        let mut p = OnePassword::new();
        assert!(p.add("vault://host/mount/key/field".to_string()).is_err());
    }

    #[tokio::test]
    #[cfg(unix)]
    async fn test_resolve_fake_cli_single_secret() {
        let fake_bin = tempdir().unwrap();
        fake_cli(&fake_bin, "op", "cat > /dev/null\nprintf 'op_secret_value'");

        let mut p = OnePassword::new();
        p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
            .unwrap();
        let extra = HashMap::from([(
            "PATH".to_string(),
            fake_bin.path().to_string_lossy().into_owned(),
        )]);
        let result = p.resolve(Path::new("."), &extra).await.unwrap();
        assert_eq!(
            result
                .get("op://my.1password.com/vault_uuid/item_uuid/password")
                .unwrap(),
            "op_secret_value"
        );
    }

    #[tokio::test]
    #[cfg(unix)]
    async fn test_resolve_error_in_stderr() {
        let fake_bin = tempdir().unwrap();
        fake_cli(&fake_bin, "op", "echo '[ERROR] authentication failed' >&2");

        let mut p = OnePassword::new();
        p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
            .unwrap();
        let extra = HashMap::from([(
            "PATH".to_string(),
            fake_bin.path().to_string_lossy().into_owned(),
        )]);
        let result = p.resolve(Path::new("."), &extra).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("1Password error"));
    }

    #[tokio::test]
    #[cfg(unix)]
    async fn test_resolve_cli_not_found() {
        let empty_bin = tempdir().unwrap();
        let mut p = OnePassword::new();
        p.add("op://my.1password.com/vault_uuid/item_uuid/password".to_string())
            .unwrap();
        let extra = HashMap::from([(
            "PATH".to_string(),
            empty_bin.path().to_string_lossy().into_owned(),
        )]);
        let result = p.resolve(Path::new("."), &extra).await;
        assert!(result.is_err());
    }

    // --- FAILING TESTS (fail before the fix, pass after) ---

    #[test]
    fn test_add_rejects_plus_in_field_name() {
        let mut p = OnePassword::new();
        let result = p.add("op://my.1password.com/vault_uuid/item_uuid/field+name".to_string());
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("UUID"));
    }

    #[test]
    fn test_add_rejects_plus_in_item_name() {
        let mut p = OnePassword::new();
        let result = p.add("op://my.1password.com/vault_uuid/item+name/field".to_string());
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("UUID"));
    }

    #[test]
    fn test_add_accepts_uuid_path_with_section() {
        let mut p = OnePassword::new();
        assert!(
            p.add(
                "op://my.1password.com/vault_uuid/item_uuid/Section_abc123/field_uuid".to_string()
            )
            .is_ok()
        );
    }

    #[tokio::test]
    #[cfg(unix)]
    async fn test_resolve_section_and_field_uuid() {
        let fake_bin = tempdir().unwrap();
        fake_cli(&fake_bin, "op", "cat > /dev/null\nprintf 'secret_value'");

        let mut p = OnePassword::new();
        p.add(
            "op://my.1password.com/vault_uuid/item_uuid/Section_abc123def/field_uuid456"
                .to_string(),
        )
        .unwrap();
        let extra = HashMap::from([(
            "PATH".to_string(),
            fake_bin.path().to_string_lossy().into_owned(),
        )]);
        let result = p.resolve(Path::new("."), &extra).await.unwrap();
        assert_eq!(
            result
                .get("op://my.1password.com/vault_uuid/item_uuid/Section_abc123def/field_uuid456")
                .unwrap(),
            "secret_value"
        );
    }
}