use std::{
    borrow::Cow,
    collections::{HashMap, HashSet},
    fmt,
};

use super::{Entry, Yarn1Lockfile};

const INDENT: &str = "  ";

fn reverse_seen_keys<'a>(
    seen_keys: &'a HashMap<&'a str, String>,
) -> HashMap<&'a str, HashSet<&'a str>> {
    let mut reverse_lookup = HashMap::new();
    for (key, value) in seen_keys.iter() {
        let keys: &mut HashSet<&str> = reverse_lookup.entry(value.as_str()).or_default();
        keys.insert(key);
    }
    reverse_lookup
}

impl Yarn1Lockfile {
    // Map from keys to seen keys
    // A "seen key" just entry.resolved with the key's package name appended to it
    // See https://github.com/yarnpkg/yarn/pull/9023/
    fn seen_keys(&self) -> HashMap<&str, String> {
        let mut seen_keys = HashMap::new();
        for (key, entry) in &self.inner {
            let Some(resolved) = entry.resolved.as_deref() else {
                continue;
            };
            let pkg_name = Pattern::new(key).name;
            let seen_key = format!("{resolved}#{pkg_name}");
            seen_keys.insert(key.as_str(), seen_key);
        }
        seen_keys
    }
}

impl fmt::Display for Yarn1Lockfile {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(
            "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile \
             v1\n\n",
        )?;
        let seen_keys = self.seen_keys();
        // A map from seen_keys to keys
        let reverse_lookup = reverse_seen_keys(&seen_keys);
        let mut added_keys: HashSet<&str> = HashSet::with_capacity(self.inner.len());
        for (key, entry) in self.inner.iter() {
            let seen_key = seen_keys.get(key.as_str());
            let seen_pattern = seen_key.is_some_and(|key| added_keys.contains(key.as_str()));
            if seen_pattern {
                continue;
            }

            let mut keys = match seen_key {
                Some(seen_key) => {
                    added_keys.insert(seen_key);
                    let all_keys = reverse_lookup
                        .get(seen_key.as_str())
                        .expect("entry in lockfile should appear as a key in reverse lookup");
                    all_keys.iter().copied().collect::<Vec<_>>()
                }
                None => {
                    // If there isn't a seen key, then there won't be any merged entries so we can
                    // just add the key as is
                    vec![key.as_str()]
                }
            };
            // Keys must be sorted before they get wrapped
            keys.sort();

            let wrapped_keys = keys.into_iter().map(maybe_wrap).collect::<Vec<_>>();
            let key_line = wrapped_keys.join(", ");

            f.write_fmt(format_args!("\n{key_line}:\n{entry}\n"))?;
        }
        Ok(())
    }
}

impl fmt::Display for Entry {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut leading = LeadingNewline::new();
        if let Some(name) = &self.name {
            f.write_fmt(format_args!(
                "{}{INDENT}name {}",
                leading.leading(),
                maybe_wrap(name)
            ))?;
        }
        f.write_fmt(format_args!(
            "{}{INDENT}version {}",
            leading.leading(),
            maybe_wrap(&self.version)
        ))?;
        if let Some(uid) = &self.uid {
            f.write_fmt(format_args!(
                "{}{INDENT}uid {}",
                leading.leading(),
                maybe_wrap(uid)
            ))?;
        }
        if let Some(resolved) = &self.resolved {
            f.write_fmt(format_args!(
                "{}{INDENT}resolved {}",
                leading.leading(),
                maybe_wrap(resolved)
            ))?;
        }
        if let Some(integrity) = &self.integrity {
            f.write_fmt(format_args!(
                "{}{INDENT}integrity {}",
                leading.leading(),
                maybe_wrap(integrity)
            ))?;
        }
        if let Some(registry) = &self.registry {
            f.write_fmt(format_args!(
                "{}{INDENT}integrity {}",
                leading.leading(),
                maybe_wrap(registry)
            ))?;
        }
        // encode deps and optional deps
        if let Some(deps) = &self.dependencies {
            f.write_fmt(format_args!("{}{INDENT}dependencies:", leading.leading()))?;
            encode_map(deps.iter().map(|(k, v)| (k.as_ref(), v.as_ref())), f)?;
        }
        if let Some(optional_deps) = &self.optional_dependencies {
            f.write_fmt(format_args!(
                "{}{INDENT}optionalDependencies:",
                leading.leading()
            ))?;
            encode_map(
                optional_deps.iter().map(|(k, v)| (k.as_ref(), v.as_ref())),
                f,
            )?;
        }
        Ok(())
    }
}

#[allow(dead_code)]
struct Pattern {
    name: String,
    range: String,
    has_version: bool,
}

impl Pattern {
    // This is an exact port of JS code. It is intentionally keeps JS-isms to make
    // patching easier in the future https://github.com/yarnpkg/yarn/blob/3c3ef8278121c0598c61caf8023d9bb2af888152/src/util/normalize-pattern.js
    fn new(pattern: &str) -> Self {
        let mut name = pattern;
        let mut range = "latest".to_owned();
        let mut has_version = false;
        let mut is_scoped = false;
        if name.starts_with('@') {
            is_scoped = true;
            name = &name[1..];
        }

        let mut parts: Vec<_> = name.split('@').collect();
        if parts.len() > 1 {
            name = parts.remove(0);
            range = parts.join("@");

            if !range.is_empty() {
                has_version = true;
            } else {
                range = "*".to_owned();
            }
        }

        let name = if is_scoped {
            format!("@{name}")
        } else {
            name.to_owned()
        };

        Self {
            name,
            range,
            has_version,
        }
    }
}

#[derive(Debug, Clone, Copy)]
enum LeadingNewline {
    First,
    Rest,
}

impl LeadingNewline {
    fn new() -> Self {
        Self::First
    }

    fn leading(&mut self) -> &'static str {
        let res = match self {
            LeadingNewline::First => "",
            LeadingNewline::Rest => "\n",
        };
        *self = Self::Rest;
        res
    }
}

fn encode_map<'a, I: Iterator<Item = (&'a str, &'a str)>>(
    entries: I,
    f: &mut fmt::Formatter<'_>,
) -> fmt::Result {
    let mut wrapped_entries = entries
        .map(|(k, v)| (maybe_wrap(k), maybe_wrap(v)))
        .collect::<Vec<_>>();
    wrapped_entries.sort_unstable_by(|(k1, _), (k2, _)| k1.cmp(k2));
    // we sort the via wrapped keys
    // then we write each line with the value wrapped as well
    for (key, value) in wrapped_entries {
        f.write_fmt(format_args!("\n{INDENT}{INDENT}{key} {value}"))?;
    }

    Ok(())
}

fn maybe_wrap(s: &str) -> Cow<'_, str> {
    match should_wrap_key(s) {
        // yarn uses JSON.stringify to escape strings
        // we approximate this behavior using serde_json
        true => serde_json::to_string(s)
            .expect("failed at encoding string as json")
            .into(),
        false => s.into(),
    }
}

// Determines if we need to wrap a key
fn should_wrap_key(s: &str) -> bool {
    // Wrap if it starts with a syml keyword
    s.starts_with("true") ||
    s.starts_with("false") ||
    // Wrap if it doesn't start with a-zA-Z
    s.chars().next().is_some_and(|c| !c.is_ascii_alphabetic()) ||
    // Wrap if it contains any unwanted chars
    s.chars().any(|c| matches!(
        c,
        ' ' | ':' | '\t' | '\r' | '\u{000B}' | '\u{000C}' | '\n' | '\\' | '"' | ',' | '[' | ']'
    ))
}

#[cfg(test)]
mod test {
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn test_should_wrap() {
        assert!(should_wrap_key("jsx-ast-utils@^2.4.1 || ^3.0.0"))
    }

    #[test]
    fn test_basic_serialization() {
        let entry = Entry {
            version: "12.2.5".into(),
            resolved: Some("https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717".into()),
            integrity: Some("sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA==".into()),
            dependencies: Some(vec![
                ("@next/env".into(), "12.2.5".into()),
                ("caniuse-lite".into(), "^1.0.30001332".into()),
                ("postcss".into(), "8.4.14".into()),
            ].into_iter().collect()),
            optional_dependencies: Some(vec![("@next/swc-win32-x64-msvc".into(), "12.2.5".into())].into_iter().collect()),
            ..Default::default()
        };
        assert_eq!(
            entry.to_string(),
            r#"  version "12.2.5"
  resolved "https://registry.yarnpkg.com/next/-/next-12.2.5.tgz#14fb5975e8841fad09553b8ef41fe1393602b717"
  integrity sha512-tBdjqX5XC/oFs/6gxrZhjmiq90YWizUYU6qOWAfat7zJwrwapJ+BYgX2PmiacunXMaRpeVT4vz5MSPSLgNkrpA==
  dependencies:
    "@next/env" "12.2.5"
    caniuse-lite "^1.0.30001332"
    postcss "8.4.14"
  optionalDependencies:
    "@next/swc-win32-x64-msvc" "12.2.5""#
        );
    }
}
