//! Cargo registry 1password credential process.
//!
//! > This crate is maintained by the Cargo team as a part of an experiment around
//! > 1password integration. We encourage people to try to use this crate in their projects and
//! > provide feedback through [issues](https://github.com/rust-lang/cargo/issues/), but do not
//! > guarantee long term maintenance.

#![allow(clippy::disallowed_methods)]
#![allow(clippy::print_stderr)]

use cargo_credential::{
    Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, Secret,
};
use serde::Deserialize;
use std::io::Read;
use std::process::{Command, Stdio};

const CARGO_TAG: &str = "cargo-registry";

/// Implementation of 1password keychain access for Cargo registries.
struct OnePasswordKeychain {
    account: Option<String>,
    vault: Option<String>,
}

/// 1password Login item type, used for the JSON output of `op item get`.
#[derive(Deserialize)]
struct Login {
    fields: Vec<Field>,
}

#[derive(Deserialize)]
struct Field {
    id: String,
    value: Option<String>,
}

/// 1password item from `op items list`.
#[derive(Deserialize)]
struct ListItem {
    id: String,
    urls: Vec<Url>,
}

#[derive(Deserialize)]
struct Url {
    href: String,
}

impl OnePasswordKeychain {
    fn new(args: &[&str]) -> Result<OnePasswordKeychain, Error> {
        let mut args = args.iter();
        let mut action = false;
        let mut account = None;
        let mut vault = None;
        while let Some(arg) = args.next() {
            match *arg {
                "--account" => {
                    account = Some(args.next().ok_or("--account needs an arg")?);
                }
                "--vault" => {
                    vault = Some(args.next().ok_or("--vault needs an arg")?);
                }
                s if s.starts_with('-') => {
                    return Err(format!("unknown option {}", s).into());
                }
                _ => {
                    if action {
                        return Err("too many arguments".into());
                    } else {
                        action = true;
                    }
                }
            }
        }
        Ok(OnePasswordKeychain {
            account: account.map(|s| s.to_string()),
            vault: vault.map(|s| s.to_string()),
        })
    }

    fn signin(&self) -> Result<Option<String>, Error> {
        // If there are any session env vars, we'll assume that this is the
        // correct account, and that the user knows what they are doing.
        if std::env::vars().any(|(name, _)| name.starts_with("OP_SESSION_")) {
            return Ok(None);
        }
        let mut cmd = Command::new("op");
        cmd.args(["signin", "--raw"]);
        if let Some(account) = &self.account {
            cmd.arg("--account");
            cmd.arg(account);
        }
        cmd.stdout(Stdio::piped());
        let mut child = cmd
            .spawn()
            .map_err(|e| format!("failed to spawn `op`: {}", e))?;
        let mut buffer = String::new();
        child
            .stdout
            .as_mut()
            .unwrap()
            .read_to_string(&mut buffer)
            .map_err(|e| format!("failed to get session from `op`: {}", e))?;
        if let Some(end) = buffer.find('\n') {
            buffer.truncate(end);
        }
        let status = child
            .wait()
            .map_err(|e| format!("failed to wait for `op`: {}", e))?;
        if !status.success() {
            return Err(format!("failed to run `op signin`: {}", status).into());
        }
        if buffer.is_empty() {
            // When using CLI integration, `op signin` returns no output,
            // so there is no need to set the session.
            return Ok(None);
        }
        Ok(Some(buffer))
    }

    fn make_cmd(&self, session: &Option<String>, args: &[&str]) -> Command {
        let mut cmd = Command::new("op");
        cmd.args(args);
        if let Some(account) = &self.account {
            cmd.arg("--account");
            cmd.arg(account);
        }
        if let Some(vault) = &self.vault {
            cmd.arg("--vault");
            cmd.arg(vault);
        }
        if let Some(session) = session {
            cmd.arg("--session");
            cmd.arg(session);
        }
        cmd
    }

    fn run_cmd(&self, mut cmd: Command) -> Result<String, Error> {
        cmd.stdout(Stdio::piped());
        let mut child = cmd
            .spawn()
            .map_err(|e| format!("failed to spawn `op`: {}", e))?;
        let mut buffer = String::new();
        child
            .stdout
            .as_mut()
            .unwrap()
            .read_to_string(&mut buffer)
            .map_err(|e| format!("failed to read `op` output: {}", e))?;
        let status = child
            .wait()
            .map_err(|e| format!("failed to wait for `op`: {}", e))?;
        if !status.success() {
            return Err(format!("`op` command exit error: {}", status).into());
        }
        Ok(buffer)
    }

    fn search(&self, session: &Option<String>, index_url: &str) -> Result<Option<String>, Error> {
        let cmd = self.make_cmd(
            session,
            &[
                "items",
                "list",
                "--categories",
                "Login",
                "--tags",
                CARGO_TAG,
                "--format",
                "json",
            ],
        );
        let buffer = self.run_cmd(cmd)?;
        let items: Vec<ListItem> = serde_json::from_str(&buffer)
            .map_err(|e| format!("failed to deserialize JSON from 1password list: {}", e))?;
        let mut matches = items
            .into_iter()
            .filter(|item| item.urls.iter().any(|url| url.href == index_url));
        match matches.next() {
            Some(login) => {
                // Should this maybe just sort on `updatedAt` and return the newest one?
                if matches.next().is_some() {
                    return Err(format!(
                        "too many 1password logins match registry `{}`, \
                        consider deleting the excess entries",
                        index_url
                    )
                    .into());
                }
                Ok(Some(login.id))
            }
            None => Ok(None),
        }
    }

    fn modify(
        &self,
        session: &Option<String>,
        id: &str,
        token: Secret<&str>,
        _name: Option<&str>,
    ) -> Result<(), Error> {
        let cmd = self.make_cmd(
            session,
            &["item", "edit", id, &format!("password={}", token.expose())],
        );
        self.run_cmd(cmd)?;
        Ok(())
    }

    fn create(
        &self,
        session: &Option<String>,
        index_url: &str,
        token: Secret<&str>,
        name: Option<&str>,
    ) -> Result<(), Error> {
        let title = match name {
            Some(name) => format!("Cargo registry token for {}", name),
            None => "Cargo registry token".to_string(),
        };
        let cmd = self.make_cmd(
            session,
            &[
                "item",
                "create",
                "--category",
                "Login",
                &format!("password={}", token.expose()),
                &format!("url={}", index_url),
                "--title",
                &title,
                "--tags",
                CARGO_TAG,
            ],
        );
        self.run_cmd(cmd)?;
        Ok(())
    }

    fn get_token(&self, session: &Option<String>, id: &str) -> Result<Secret<String>, Error> {
        let cmd = self.make_cmd(session, &["item", "get", "--format=json", id]);
        let buffer = self.run_cmd(cmd)?;
        let item: Login = serde_json::from_str(&buffer)
            .map_err(|e| format!("failed to deserialize JSON from 1password get: {}", e))?;
        let password = item.fields.into_iter().find(|item| item.id == "password");
        match password {
            Some(password) => password
                .value
                .map(Secret::from)
                .ok_or("missing password value for entry".into()),
            None => Err("could not find password field".into()),
        }
    }

    fn delete(&self, session: &Option<String>, id: &str) -> Result<(), Error> {
        let cmd = self.make_cmd(session, &["item", "delete", id]);
        self.run_cmd(cmd)?;
        Ok(())
    }
}

pub struct OnePasswordCredential {}

impl Credential for OnePasswordCredential {
    fn perform(
        &self,
        registry: &RegistryInfo<'_>,
        action: &Action<'_>,
        args: &[&str],
    ) -> Result<CredentialResponse, Error> {
        let op = OnePasswordKeychain::new(args)?;
        match action {
            Action::Get(_) => {
                let session = op.signin()?;
                if let Some(id) = op.search(&session, registry.index_url)? {
                    op.get_token(&session, &id)
                        .map(|token| CredentialResponse::Get {
                            token,
                            cache: CacheControl::Session,
                            operation_independent: true,
                        })
                } else {
                    Err(Error::NotFound)
                }
            }
            Action::Login(options) => {
                let session = op.signin()?;
                // Check if an item already exists.
                if let Some(id) = op.search(&session, registry.index_url)? {
                    eprintln!("note: token already exists for `{}`", registry.index_url);
                    let token = cargo_credential::read_token(options, registry)?;
                    op.modify(&session, &id, token.as_deref(), None)?;
                } else {
                    let token = cargo_credential::read_token(options, registry)?;
                    op.create(&session, registry.index_url, token.as_deref(), None)?;
                }
                Ok(CredentialResponse::Login)
            }
            Action::Logout => {
                let session = op.signin()?;
                // Check if an item already exists.
                if let Some(id) = op.search(&session, registry.index_url)? {
                    op.delete(&session, &id)?;
                    Ok(CredentialResponse::Logout)
                } else {
                    Err(Error::NotFound)
                }
            }
            _ => Err(Error::OperationNotSupported),
        }
    }
}

fn main() {
    cargo_credential::main(OnePasswordCredential {});
}
