use std::{ffi::OsString, io::Read};

use crate::{
    command::{ArchiveSource, BundleSpec, CleanupPolicy, EnvAssignment, InnerCommand},
    error::{Error, Result},
};

/// Parses full `bdlx bundle` arguments, including the required outer `--`.
pub fn parse_bundle_args<'a, R: Read + ?Sized>(
    args: &[OsString],
    stdin: &'a mut R,
) -> Result<BundleSpec<'a, R>> {
    let delimiter = args
        .iter()
        .position(|arg| arg == "--")
        .ok_or(Error::MissingBundleDelimiter)?;

    let (outer, inner_with_delim) = args.split_at(delimiter);
    let inner = &inner_with_delim[1..];
    if inner.is_empty() {
        return Err(Error::MissingInnerCommand);
    }

    let mut cleanup = CleanupPolicy::Success;
    let mut shared = false;
    let mut archives = Vec::new();
    let mut env = Vec::new();
    let mut stdin = Some(stdin);
    let mut index = 0usize;

    while index < outer.len() {
        let token = os_to_string(&outer[index], "bundle argument")?;
        match token.as_str() {
            "--shared" => {
                shared = true;
                index += 1;
            }
            "--rm" => {
                let value = outer
                    .get(index + 1)
                    .ok_or_else(|| Error::Usage("missing value after `--rm`".to_owned()))?;
                cleanup = parse_cleanup_policy(&os_to_string(value, "cleanup policy")?)?;
                index += 2;
            }
            _ if token.starts_with('-') && token != "-" => {
                return Err(Error::UnknownBundleOption(token));
            }
            _ => {
                if token == "-" {
                    let reader = stdin.take().ok_or(Error::DuplicateStdin)?;
                    archives.push(ArchiveSource::reader("stdin archive", reader));
                } else if let Some(assignment) = parse_env_assignment(&token)? {
                    env.push(assignment);
                } else {
                    archives.push(ArchiveSource::path(token));
                }
                index += 1;
            }
        }
    }

    if shared && cleanup != CleanupPolicy::Never {
        return Err(Error::SharedRequiresRmNever);
    }

    let command = InnerCommand {
        program: os_to_string(&inner[0], "inner command")?,
        args: inner[1..]
            .iter()
            .map(|arg| os_to_string(arg, "inner command argument"))
            .collect::<Result<Vec<_>>>()?,
    };

    Ok(BundleSpec {
        cleanup,
        shared,
        archives,
        env,
        command,
    })
}

/// Parses the already-split outer and inner halves of `bdlx bundle`.
///
/// This is the parser used by the CLI after it finds the single outer `--`.
pub fn parse_bundle_parts<'a, R: Read + ?Sized>(
    cleanup: CleanupPolicy,
    shared: bool,
    items: &[OsString],
    command: &[OsString],
    stdin: &'a mut R,
) -> Result<BundleSpec<'a, R>> {
    if command.is_empty() {
        return Err(Error::MissingInnerCommand);
    }

    let mut archives = Vec::new();
    let mut env = Vec::new();
    let mut stdin = Some(stdin);

    for token in items {
        let token = os_to_string(token, "bundle argument")?;
        if token == "-" {
            let reader = stdin.take().ok_or(Error::DuplicateStdin)?;
            archives.push(ArchiveSource::reader("stdin archive", reader));
        } else if let Some(assignment) = parse_env_assignment(&token)? {
            env.push(assignment);
        } else {
            archives.push(ArchiveSource::path(token));
        }
    }

    if shared && cleanup != CleanupPolicy::Never {
        return Err(Error::SharedRequiresRmNever);
    }

    Ok(BundleSpec {
        cleanup,
        shared,
        archives,
        env,
        command: InnerCommand {
            program: os_to_string(&command[0], "inner command")?,
            args: command[1..]
                .iter()
                .map(|arg| os_to_string(arg, "inner command argument"))
                .collect::<Result<Vec<_>>>()?,
        },
    })
}

fn os_to_string(value: &OsString, context: &'static str) -> Result<String> {
    value
        .clone()
        .into_string()
        .map_err(|value| Error::NonUtf8Arg { context, value })
}

fn parse_cleanup_policy(value: &str) -> Result<CleanupPolicy> {
    match value {
        "success" => Ok(CleanupPolicy::Success),
        "failure" => Ok(CleanupPolicy::Failure),
        "always" => Ok(CleanupPolicy::Always),
        "never" => Ok(CleanupPolicy::Never),
        _ => Err(Error::Usage(format!(
            "invalid `--rm` value `{}`; expected one of: success, failure, always, never",
            value
        ))),
    }
}

fn parse_env_assignment(token: &str) -> Result<Option<EnvAssignment>> {
    let Some((name, value)) = token.split_once('=') else {
        return Ok(None);
    };

    if is_valid_env_name(name) {
        return Ok(Some(EnvAssignment {
            name: name.to_owned(),
            value: value.to_owned(),
        }));
    }

    if token.contains('/') || token.starts_with('.') {
        return Ok(None);
    }

    if token.contains('=') {
        return Err(Error::InvalidEnvAssignment {
            assignment: token.to_owned(),
        });
    }

    Ok(None)
}

fn is_valid_env_name(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(ch) if ch == '_' || ch.is_ascii_alphabetic() => {}
        _ => return false,
    }
    chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
    use super::parse_bundle_args;
    use crate::command::{ArchiveSource, CleanupPolicy};
    use std::ffi::OsString;
    use std::path::PathBuf;

    fn os_args(items: &[&str]) -> Vec<OsString> {
        items.iter().map(OsString::from).collect::<Vec<_>>()
    }

    #[test]
    fn parses_bundle_items() {
        let mut stdin = std::io::empty();
        let spec = parse_bundle_args(
            &os_args(&[
                "--rm",
                "never",
                "--shared",
                "FOO=bar",
                "archive.tar",
                "--",
                "env",
            ]),
            &mut stdin,
        )
        .unwrap();
        assert!(spec.shared);
        assert_eq!(spec.cleanup, CleanupPolicy::Never);
        assert_eq!(spec.env[0].name, "FOO");
        assert_eq!(spec.env[0].value, "bar");
        assert!(matches!(
            spec.archives.first(),
            Some(ArchiveSource::Path(path)) if path == &PathBuf::from("archive.tar")
        ));
    }

    #[test]
    fn requires_delimiter() {
        let mut stdin = std::io::empty();
        assert!(matches!(
            parse_bundle_args(&os_args(&["archive.tar"]), &mut stdin),
            Err(crate::Error::MissingBundleDelimiter)
        ));
    }

    #[test]
    fn rejects_duplicate_stdin() {
        let mut stdin = std::io::empty();
        assert!(matches!(
            parse_bundle_args(&os_args(&["-", "-", "--", "sh"]), &mut stdin),
            Err(crate::Error::DuplicateStdin)
        ));
    }

    #[test]
    fn treats_slash_assignment_like_archive_path() {
        let mut stdin = std::io::empty();
        let spec = parse_bundle_args(&os_args(&["./FOO=bar", "--", "sh"]), &mut stdin).unwrap();
        assert!(matches!(
            spec.archives.first(),
            Some(ArchiveSource::Path(path)) if path == &PathBuf::from("./FOO=bar")
        ));
    }

    #[test]
    fn rejects_invalid_env_name() {
        let mut stdin = std::io::empty();
        assert!(matches!(
            parse_bundle_args(&os_args(&["1FOO=bar", "--", "sh"]), &mut stdin),
            Err(crate::Error::InvalidEnvAssignment { .. })
        ));
    }
}
