//! Parser for package manager catalog declarations.
//!
//! pnpm supports two catalog forms:
//! - the top-level `catalog:` map (the "default" catalog)
//! - the top-level `catalogs:` map of named catalogs
//!
//! Bun supports the same shapes in root `package.json`, usually under
//! `workspaces.catalog` / `workspaces.catalogs`, with top-level `catalog` /
//! `catalogs` accepted as an alternative.
//!
//! ```yaml
//! catalog:
//!   react: ^18.2.0
//!   "@scope/lib": ^1.0.0
//!
//! catalogs:
//!   react17:
//!     react: ^17.0.2
//!     react-dom: ^17.0.2
//! ```
//!
//! Workspace packages reference catalog entries from their `dependencies`
//! (and friends) with the `catalog:` protocol:
//!
//! ```json
//! { "dependencies": { "react": "catalog:", "old-react": "catalog:react17" } }
//! ```
//!
//! For the unused-catalog-entry detector we need both the structured catalog
//! map and the 1-based line number of each entry in the source so findings
//! can point users to the exact line. `serde_yaml_ng` gives us the structural
//! parse; a second targeted scan over the raw source recovers the line
//! numbers.

/// Structured catalog data extracted from a package manager catalog source.
#[derive(Debug, Clone, Default)]
pub struct PnpmCatalogData {
    /// Catalogs found in the file. The default catalog (top-level `catalog:`)
    /// always appears first with `name = "default"` when present; named
    /// catalogs follow in source order.
    pub catalogs: Vec<PnpmCatalog>,
    /// Named catalogs under `catalogs:` that declare no package entries.
    ///
    /// The top-level `catalog:` map is intentionally not represented here:
    /// some repos keep it as a stable hook even when currently empty.
    pub empty_named_catalog_groups: Vec<PnpmCatalogGroup>,
}

/// A single catalog (the default or a named one).
#[derive(Debug, Clone)]
pub struct PnpmCatalog {
    /// Catalog name. `"default"` for the top-level `catalog:` map, or the
    /// named catalog key for entries declared under `catalogs.<name>:`.
    pub name: String,
    /// Entries declared in this catalog, in source order.
    pub entries: Vec<PnpmCatalogEntry>,
}

/// A single entry inside a catalog.
#[derive(Debug, Clone)]
pub struct PnpmCatalogEntry {
    /// Package name declared in the catalog (e.g. `"react"`, `"@scope/lib"`).
    pub package_name: String,
    /// 1-based line number of the entry within the source file.
    pub line: u32,
}

/// A named catalog group under `catalogs:` with no package entries.
#[derive(Debug, Clone)]
pub struct PnpmCatalogGroup {
    /// Catalog group name (e.g. `"react17"` for `catalogs.react17`).
    pub name: String,
    /// 1-based line number of the group header within the source file.
    pub line: u32,
}

/// Parse the catalog sections of a `pnpm-workspace.yaml` file.
///
/// Returns an empty `PnpmCatalogData` when the file has no catalog data, when
/// the YAML is malformed, or when the catalog sections are present but empty.
/// All non-catalog top-level keys (`packages`, `catalog`, `catalogs`, etc.)
/// are ignored.
#[must_use]
pub fn parse_pnpm_catalog_data(source: &str) -> PnpmCatalogData {
    let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
        Ok(v) => v,
        Err(_) => return PnpmCatalogData::default(),
    };
    let Some(mapping) = value.as_mapping() else {
        return PnpmCatalogData::default();
    };

    let line_index = build_line_index(source);
    let mut catalogs = Vec::new();
    let mut empty_named_catalog_groups = Vec::new();

    collect_yaml_default_catalog(mapping.get("catalog"), &line_index, &mut catalogs);
    collect_yaml_named_catalogs(
        mapping.get("catalogs"),
        &line_index,
        &mut catalogs,
        &mut empty_named_catalog_groups,
    );

    PnpmCatalogData {
        catalogs,
        empty_named_catalog_groups,
    }
}

/// Push the default pnpm catalog when it contains package entries.
fn collect_yaml_default_catalog(
    default_value: Option<&serde_yaml_ng::Value>,
    line_index: &CatalogLineIndex,
    catalogs: &mut Vec<PnpmCatalog>,
) {
    let Some(default_map) = default_value.and_then(serde_yaml_ng::Value::as_mapping) else {
        return;
    };
    let entries = collect_entries(default_map, line_index, "default");
    if !entries.is_empty() {
        catalogs.push(PnpmCatalog {
            name: "default".to_string(),
            entries,
        });
    }
}

/// Split named pnpm `catalogs:` entries into populated catalogs and empty groups.
fn collect_yaml_named_catalogs(
    named_value: Option<&serde_yaml_ng::Value>,
    line_index: &CatalogLineIndex,
    catalogs: &mut Vec<PnpmCatalog>,
    empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
    let Some(named_map) = named_value.and_then(serde_yaml_ng::Value::as_mapping) else {
        return;
    };
    for (name_value, catalog_value) in named_map {
        let Some(name) = name_value.as_str() else {
            continue;
        };
        if let Some(catalog_map) = catalog_value.as_mapping() {
            let entries = collect_entries(catalog_map, line_index, name);
            if entries.is_empty() {
                push_yaml_empty_catalog_group(name, line_index, empty_named_catalog_groups);
            } else {
                catalogs.push(PnpmCatalog {
                    name: name.to_string(),
                    entries,
                });
            }
        } else if catalog_value.is_null() {
            push_yaml_empty_catalog_group(name, line_index, empty_named_catalog_groups);
        }
    }
}

fn push_yaml_empty_catalog_group(
    name: &str,
    line_index: &CatalogLineIndex,
    empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
    if let Some(line) = line_index.group_line_for(name) {
        empty_named_catalog_groups.push(PnpmCatalogGroup {
            name: name.to_string(),
            line,
        });
    }
}

/// Parse Bun catalog sections from a root `package.json` file.
///
/// Bun accepts `workspaces.catalog` / `workspaces.catalogs` and the same
/// `catalog` / `catalogs` keys at package.json top level. The nested
/// `workspaces` form is preferred when both forms exist for the same section.
#[must_use]
pub fn parse_package_json_catalog_data(source: &str) -> PnpmCatalogData {
    let value: serde_json::Value = match serde_json::from_str(source.trim_start_matches('\u{FEFF}'))
    {
        Ok(value) => value,
        Err(_) => return PnpmCatalogData::default(),
    };
    let Some(root) = value.as_object() else {
        return PnpmCatalogData::default();
    };

    let workspaces = root
        .get("workspaces")
        .and_then(serde_json::Value::as_object);
    let workspace_default_value = workspaces.and_then(|workspace| workspace.get("catalog"));
    let workspace_named_value = workspaces.and_then(|workspace| workspace.get("catalogs"));
    let default_value = workspace_default_value.or_else(|| root.get("catalog"));
    let named_value = workspace_named_value.or_else(|| root.get("catalogs"));
    let default_line_key = if workspace_default_value.is_some() {
        workspace_catalog_key("default")
    } else {
        "default".to_string()
    };
    let line_index = build_package_json_line_index(source);

    let mut catalogs = Vec::new();
    let mut empty_named_catalog_groups = Vec::new();

    collect_json_default_catalog(default_value, &line_index, &default_line_key, &mut catalogs);
    collect_json_named_catalogs(
        named_value,
        workspace_named_value.is_some(),
        &line_index,
        &mut catalogs,
        &mut empty_named_catalog_groups,
    );

    PnpmCatalogData {
        catalogs,
        empty_named_catalog_groups,
    }
}

/// Push the default catalog (top-level or `workspaces.catalog`) when non-empty.
fn collect_json_default_catalog(
    default_value: Option<&serde_json::Value>,
    line_index: &CatalogLineIndex,
    default_line_key: &str,
    catalogs: &mut Vec<PnpmCatalog>,
) {
    if let Some(default_map) = default_value.and_then(serde_json::Value::as_object) {
        let entries = collect_json_entries(default_map, line_index, default_line_key);
        if !entries.is_empty() {
            catalogs.push(PnpmCatalog {
                name: "default".to_string(),
                entries,
            });
        }
    }
}

/// Split named `catalogs:` entries into populated catalogs and empty groups.
fn collect_json_named_catalogs(
    named_value: Option<&serde_json::Value>,
    named_from_workspace: bool,
    line_index: &CatalogLineIndex,
    catalogs: &mut Vec<PnpmCatalog>,
    empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
    let Some(named_map) = named_value.and_then(serde_json::Value::as_object) else {
        return;
    };
    for (name, catalog_value) in named_map {
        let line_key = if named_from_workspace {
            workspace_catalog_key(name)
        } else {
            name.clone()
        };
        if let Some(catalog_map) = catalog_value.as_object() {
            let entries = collect_json_entries(catalog_map, line_index, &line_key);
            if entries.is_empty() {
                empty_named_catalog_groups.push(PnpmCatalogGroup {
                    name: name.clone(),
                    line: line_index.group_line_for(&line_key).unwrap_or(1),
                });
            } else {
                catalogs.push(PnpmCatalog {
                    name: name.clone(),
                    entries,
                });
            }
        } else if catalog_value.is_null() {
            empty_named_catalog_groups.push(PnpmCatalogGroup {
                name: name.clone(),
                line: line_index.group_line_for(&line_key).unwrap_or(1),
            });
        }
    }
}

fn collect_entries(
    mapping: &serde_yaml_ng::Mapping,
    line_index: &CatalogLineIndex,
    catalog_name: &str,
) -> Vec<PnpmCatalogEntry> {
    mapping
        .iter()
        .filter_map(|(k, _)| {
            let pkg = k.as_str()?;
            let line = line_index.line_for(catalog_name, pkg)?;
            Some(PnpmCatalogEntry {
                package_name: pkg.to_string(),
                line,
            })
        })
        .collect()
}

fn collect_json_entries(
    mapping: &serde_json::Map<String, serde_json::Value>,
    line_index: &CatalogLineIndex,
    catalog_name: &str,
) -> Vec<PnpmCatalogEntry> {
    mapping
        .keys()
        .map(|pkg| PnpmCatalogEntry {
            package_name: pkg.clone(),
            line: line_index.line_for(catalog_name, pkg).unwrap_or(1),
        })
        .collect()
}

fn workspace_catalog_key(name: &str) -> String {
    format!("workspaces.{name}")
}

/// Maps `(catalog_name, package_name)` to its 1-based source line.
///
/// `catalog_name` is `"default"` for entries under the top-level `catalog`
/// key, the named catalog key for entries under top-level `catalogs.<name>`,
/// or `workspaces.<name>` for Bun package.json catalogs nested below
/// `workspaces`.
struct CatalogLineIndex {
    entries: Vec<((String, String), u32)>,
    groups: Vec<(String, u32)>,
}

impl CatalogLineIndex {
    fn line_for(&self, catalog_name: &str, package_name: &str) -> Option<u32> {
        self.entries
            .iter()
            .find(|((cat, pkg), _)| cat == catalog_name && pkg == package_name)
            .map(|(_, line)| *line)
    }

    fn group_line_for(&self, catalog_name: &str) -> Option<u32> {
        self.groups
            .iter()
            .find(|(name, _)| name == catalog_name)
            .map(|(_, line)| *line)
    }
}

/// Walk the raw YAML source to map each catalog entry to its 1-based line
/// number. This is a small section-aware scanner: it tracks whether the
/// current line falls inside `catalog:` (the default catalog) or inside
/// `catalogs.<name>:` (a named catalog), and records each key at the
/// expected indentation level.
fn build_line_index(source: &str) -> CatalogLineIndex {
    let mut scan = YamlCatalogScan::default();

    for (idx, raw_line) in source.lines().enumerate() {
        let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
        scan.record_line(raw_line, line_no);
    }

    scan.finish()
}

#[derive(Default)]
struct YamlCatalogScan {
    entries: Vec<((String, String), u32)>,
    groups: Vec<(String, u32)>,
    section: Section,
    named_catalog: Option<(String, usize)>,
}

impl YamlCatalogScan {
    fn record_line(&mut self, raw_line: &str, line_no: u32) {
        let trimmed = strip_inline_comment(raw_line);
        let trimmed_left = trimmed.trim_start();
        let indent = trimmed.len() - trimmed_left.len();

        if trimmed_left.is_empty() {
            return;
        }

        if indent == 0 {
            self.enter_top_level_section(trimmed_left);
            return;
        }

        self.record_catalog_key(trimmed_left, indent, line_no);
    }

    fn enter_top_level_section(&mut self, trimmed_left: &str) {
        self.section = if trimmed_left.starts_with("catalogs:") {
            Section::NamedCatalogs
        } else if trimmed_left.starts_with("catalog:") {
            Section::DefaultCatalog
        } else {
            Section::None
        };
        self.named_catalog = None;
    }

    fn record_catalog_key(&mut self, trimmed_left: &str, indent: usize, line_no: u32) {
        let Some(name) = parse_key(trimmed_left) else {
            return;
        };

        match self.section {
            Section::None => {}
            Section::DefaultCatalog => {
                self.entries.push((("default".to_string(), name), line_no));
            }
            Section::NamedCatalogs => self.record_named_catalog_key(name, indent, line_no),
        }
    }

    fn record_named_catalog_key(&mut self, name: String, indent: usize, line_no: u32) {
        if let Some((catalog_name, existing_indent)) = &self.named_catalog
            && indent > *existing_indent
        {
            self.entries.push(((catalog_name.clone(), name), line_no));
            return;
        }

        self.groups.push((name.clone(), line_no));
        self.named_catalog = Some((name, indent));
    }

    fn finish(self) -> CatalogLineIndex {
        CatalogLineIndex {
            entries: self.entries,
            groups: self.groups,
        }
    }
}

/// Brace-depth scanner state for the package.json catalog line index.
#[derive(Default)]
struct JsonCatalogScan {
    entries: Vec<((String, String), u32)>,
    groups: Vec<(String, u32)>,
    current_depth: u32,
    workspaces_depth: Option<u32>,
    current_section_prefix: Option<&'static str>,
    section: Section,
    section_depth: u32,
    named_catalog: Option<(String, u32)>,
}

impl JsonCatalogScan {
    /// Record a catalog entry or group header for the current key, if the
    /// active section and brace depth place it inside a catalog.
    fn record_key(&mut self, name: &str, parent_depth: u32, line_no: u32) {
        match self.section {
            Section::DefaultCatalog if parent_depth == self.section_depth => {
                let catalog_name = self.current_section_prefix.map_or_else(
                    || "default".to_string(),
                    |prefix| format!("{prefix}.default"),
                );
                self.entries
                    .push(((catalog_name, name.to_string()), line_no));
            }
            Section::NamedCatalogs if parent_depth == self.section_depth => {
                let catalog_name = self
                    .current_section_prefix
                    .map_or_else(|| name.to_string(), |prefix| format!("{prefix}.{name}"));
                self.groups.push((catalog_name.clone(), line_no));
                self.named_catalog = Some((catalog_name, parent_depth));
            }
            Section::NamedCatalogs => {
                if let Some((catalog_name, group_depth)) = &self.named_catalog
                    && parent_depth == group_depth.saturating_add(1)
                {
                    self.entries
                        .push(((catalog_name.clone(), name.to_string()), line_no));
                }
            }
            Section::DefaultCatalog | Section::None => {}
        }
    }

    /// Enter the `workspaces`, `catalog`, or `catalogs` section when the current
    /// key opens one at a supported parent depth.
    fn enter_section(&mut self, name: &str, parent_depth: u32, opens: u32) {
        let in_supported_parent = parent_depth == 1
            || self
                .workspaces_depth
                .is_some_and(|depth| parent_depth == depth);
        if parent_depth == 1 && name == "workspaces" && opens > 0 {
            self.workspaces_depth = Some(parent_depth.saturating_add(1));
        }
        if in_supported_parent && name == "catalog" && opens > 0 {
            self.begin_catalog_section(Section::DefaultCatalog, parent_depth);
        } else if in_supported_parent && name == "catalogs" && opens > 0 {
            self.begin_catalog_section(Section::NamedCatalogs, parent_depth);
        }
    }

    /// Set the active catalog section, its depth, and the workspace prefix.
    fn begin_catalog_section(&mut self, section: Section, parent_depth: u32) {
        self.section = section;
        self.section_depth = parent_depth.saturating_add(1);
        self.current_section_prefix = self
            .workspaces_depth
            .is_some_and(|depth| parent_depth == depth)
            .then_some("workspaces");
        self.named_catalog = None;
    }

    /// Drop section/workspace state once the brace depth exits their scope.
    fn close_exited_scopes(&mut self) {
        if matches!(
            self.section,
            Section::DefaultCatalog | Section::NamedCatalogs
        ) && self.current_depth < self.section_depth
        {
            self.section = Section::None;
            self.current_section_prefix = None;
            self.named_catalog = None;
        }
        if let Some(depth) = self.workspaces_depth
            && self.current_depth < depth
        {
            self.workspaces_depth = None;
        }
    }
}

fn build_package_json_line_index(source: &str) -> CatalogLineIndex {
    let mut scan = JsonCatalogScan::default();

    for (idx, raw_line) in source.lines().enumerate() {
        let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
        let trimmed = raw_line.trim();
        if trimmed.is_empty() {
            continue;
        }

        let key = parse_json_key(trimmed);
        let parent_depth = scan.current_depth;

        if let Some(name) = key {
            scan.record_key(name, parent_depth, line_no);
        }

        let (opens, closes) = count_json_braces(raw_line);
        let depth_after_opens = scan.current_depth.saturating_add(opens);

        if let Some(name) = key {
            scan.enter_section(name, parent_depth, opens);
        }

        scan.current_depth = depth_after_opens.saturating_sub(closes);
        scan.close_exited_scopes();
    }

    CatalogLineIndex {
        entries: scan.entries,
        groups: scan.groups,
    }
}

fn parse_json_key(trimmed: &str) -> Option<&str> {
    let rest = trimmed.strip_prefix('"')?;
    let end = rest.find('"')?;
    let after = rest[end.saturating_add(1)..].trim_start();
    after.starts_with(':').then_some(&rest[..end])
}

fn count_json_braces(line: &str) -> (u32, u32) {
    let mut opens: u32 = 0;
    let mut closes: u32 = 0;
    let mut in_string = false;
    let mut escaped = false;
    for ch in line.chars() {
        if escaped {
            escaped = false;
            continue;
        }
        if ch == '\\' {
            escaped = true;
            continue;
        }
        if ch == '"' {
            in_string = !in_string;
            continue;
        }
        if in_string {
            continue;
        }
        match ch {
            '{' => opens = opens.saturating_add(1),
            '}' => closes = closes.saturating_add(1),
            _ => {}
        }
    }
    (opens, closes)
}

#[derive(Debug, Clone, Copy, Default)]
enum Section {
    #[default]
    None,
    DefaultCatalog,
    NamedCatalogs,
}

/// Strip an unquoted trailing `# ...` comment from a single line. Preserves
/// `#` characters inside quoted strings so `"# in quotes": "value"` is left
/// alone.
pub(super) fn strip_inline_comment(line: &str) -> &str {
    let bytes = line.as_bytes();
    let mut in_single = false;
    let mut in_double = false;
    for (i, &b) in bytes.iter().enumerate() {
        match b {
            b'\'' if !in_double => in_single = !in_single,
            b'"' if !in_single => in_double = !in_double,
            b'#' if !in_single && !in_double => {
                let head = &line[..i];
                return head.trim_end();
            }
            _ => {}
        }
    }
    line.trim_end()
}

/// Parse a key declaration of the form `key:` or `key: value`, returning just
/// the (unquoted) key. Returns `None` when the line is not a key declaration
/// (e.g., a list item `- foo`, a block scalar marker, or malformed).
pub(super) fn parse_key(line: &str) -> Option<String> {
    let bytes = line.as_bytes();
    if bytes.is_empty() {
        return None;
    }
    let first = bytes[0];
    if first == b'-' || first == b'#' {
        return None;
    }

    if first == b'"' || first == b'\'' {
        let quote = first;
        let mut i = 1;
        while i < bytes.len() {
            let b = bytes[i];
            if b == b'\\' && i + 1 < bytes.len() {
                i += 2;
                continue;
            }
            if b == quote {
                let key = &line[1..i];
                let rest = &line[i + 1..];
                let trimmed = rest.trim_start();
                if trimmed.starts_with(':') {
                    return Some(unescape_key(key));
                }
                return None;
            }
            i += 1;
        }
        return None;
    }

    let colon_pos = bytes.iter().position(|&b| b == b':')?;
    let key = line[..colon_pos].trim();
    if key.is_empty() {
        return None;
    }
    if key.contains(['{', '[', '&', '*', '!']) {
        return None;
    }
    Some(key.to_string())
}

fn unescape_key(raw: &str) -> String {
    let mut out = String::with_capacity(raw.len());
    let mut chars = raw.chars();
    while let Some(c) = chars.next() {
        if c == '\\'
            && let Some(next) = chars.next()
        {
            match next {
                'n' => out.push('\n'),
                't' => out.push('\t'),
                '"' => out.push('"'),
                '\\' => out.push('\\'),
                other => {
                    out.push('\\');
                    out.push(other);
                }
            }
        } else {
            out.push(c);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_default_catalog() {
        let yaml = "packages:\n  - 'packages/*'\n\ncatalog:\n  react: ^18.2.0\n  is-even: ^1.0.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs.len(), 1);
        let default = &data.catalogs[0];
        assert_eq!(default.name, "default");
        assert_eq!(default.entries.len(), 2);
        assert_eq!(default.entries[0].package_name, "react");
        assert_eq!(default.entries[0].line, 5);
        assert_eq!(default.entries[1].package_name, "is-even");
        assert_eq!(default.entries[1].line, 6);
    }

    #[test]
    fn parses_bun_workspaces_catalog() {
        let json = r#"{
  "name": "demo",
  "workspaces": {
    "packages": ["packages/*"],
    "catalog": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0"
    },
    "catalogs": {
      "testing": {
        "vitest": "^3.0.0"
      },
      "empty": {}
    }
  }
}
"#;
        let data = parse_package_json_catalog_data(json);
        assert_eq!(data.catalogs.len(), 2);
        assert_eq!(data.catalogs[0].name, "default");
        assert_eq!(data.catalogs[0].entries[0].package_name, "react");
        assert_eq!(data.catalogs[0].entries[0].line, 6);
        assert_eq!(data.catalogs[1].name, "testing");
        assert_eq!(data.catalogs[1].entries[0].package_name, "vitest");
        assert_eq!(data.catalogs[1].entries[0].line, 11);
        let empty: Vec<_> = data
            .empty_named_catalog_groups
            .iter()
            .map(|group| (group.name.as_str(), group.line))
            .collect();
        assert_eq!(empty, vec![("empty", 13)]);
    }

    #[test]
    fn parses_bun_top_level_catalog_fallback() {
        let json = r#"{
  "name": "demo",
  "workspaces": ["packages/*"],
  "catalog": {
    "bun-types": "^1.3.0"
  },
  "catalogs": {
    "testing": {
      "vitest": "^3.0.0"
    }
  }
}
"#;
        let data = parse_package_json_catalog_data(json);
        assert_eq!(data.catalogs.len(), 2);
        assert_eq!(data.catalogs[0].name, "default");
        assert_eq!(data.catalogs[0].entries[0].package_name, "bun-types");
        assert_eq!(data.catalogs[0].entries[0].line, 5);
        assert_eq!(data.catalogs[1].name, "testing");
        assert_eq!(data.catalogs[1].entries[0].line, 9);
    }

    #[test]
    fn workspaces_catalog_takes_precedence_over_top_level_catalog() {
        let json = r#"{
  "workspaces": {
    "packages": ["packages/*"],
    "catalog": {
      "react": "^19.0.0"
    }
  },
  "catalog": {
    "react": "^18.0.0",
    "vue": "^3.0.0"
  }
}
"#;
        let data = parse_package_json_catalog_data(json);
        assert_eq!(data.catalogs.len(), 1);
        let entries: Vec<_> = data.catalogs[0]
            .entries
            .iter()
            .map(|entry| entry.package_name.as_str())
            .collect();
        assert_eq!(entries, vec!["react"]);
        assert_eq!(data.catalogs[0].entries[0].line, 5);
    }

    #[test]
    fn workspaces_catalog_line_wins_when_top_level_catalog_appears_first() {
        let json = r#"{
  "catalog": {
    "react": "^18.0.0"
  },
  "workspaces": {
    "packages": ["packages/*"],
    "catalog": {
      "react": "^19.0.0"
    }
  }
}
"#;
        let data = parse_package_json_catalog_data(json);
        assert_eq!(data.catalogs.len(), 1);
        assert_eq!(data.catalogs[0].entries[0].package_name, "react");
        assert_eq!(data.catalogs[0].entries[0].line, 8);
    }

    #[test]
    fn parses_named_catalogs() {
        let yaml = "catalogs:\n  react17:\n    react: ^17.0.2\n    react-dom: ^17.0.2\n  ui:\n    headlessui: ^2.0.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs.len(), 2);
        assert_eq!(data.catalogs[0].name, "react17");
        assert_eq!(data.catalogs[0].entries.len(), 2);
        assert_eq!(data.catalogs[0].entries[0].package_name, "react");
        assert_eq!(data.catalogs[0].entries[0].line, 3);
        assert_eq!(data.catalogs[1].name, "ui");
        assert_eq!(data.catalogs[1].entries[0].package_name, "headlessui");
        assert_eq!(data.catalogs[1].entries[0].line, 6);
        assert!(data.empty_named_catalog_groups.is_empty());
    }

    #[test]
    fn handles_default_and_named_together() {
        let yaml = "catalog:\n  react: ^18\n\ncatalogs:\n  legacy:\n    react: ^17\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs.len(), 2);
        assert_eq!(data.catalogs[0].name, "default");
        assert_eq!(data.catalogs[0].entries[0].line, 2);
        assert_eq!(data.catalogs[1].name, "legacy");
        assert_eq!(data.catalogs[1].entries[0].line, 6);
    }

    #[test]
    fn handles_quoted_keys() {
        let yaml = "catalog:\n  \"@scope/lib\": ^1.0.0\n  'my-pkg': ^2.0.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        let default = &data.catalogs[0];
        assert_eq!(default.entries[0].package_name, "@scope/lib");
        assert_eq!(default.entries[0].line, 2);
        assert_eq!(default.entries[1].package_name, "my-pkg");
        assert_eq!(default.entries[1].line, 3);
    }

    #[test]
    fn handles_inline_comments() {
        let yaml = "catalog:\n  react: ^18  # pin until #1234\n  is-even: ^1.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs[0].entries.len(), 2);
        assert_eq!(data.catalogs[0].entries[0].package_name, "react");
        assert_eq!(data.catalogs[0].entries[1].package_name, "is-even");
        assert_eq!(data.catalogs[0].entries[1].line, 3);
    }

    #[test]
    fn handles_four_space_indentation() {
        let yaml = "catalog:\n    react: ^18.2.0\n    vue: ^3.4.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs[0].entries.len(), 2);
        assert_eq!(data.catalogs[0].entries[0].line, 2);
        assert_eq!(data.catalogs[0].entries[1].line, 3);
    }

    #[test]
    fn empty_catalog_returns_no_catalogs() {
        let yaml = "catalog: {}\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert!(data.catalogs.is_empty());
        assert!(data.empty_named_catalog_groups.is_empty());
    }

    #[test]
    fn tracks_empty_named_catalog_groups() {
        let yaml = "catalog:\n  react: ^18\n\ncatalogs:\n  react17: {}\n  legacy:\n    # retained note\n  vue3:\n    vue: ^3.4.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs.len(), 2);
        let empty: Vec<_> = data
            .empty_named_catalog_groups
            .iter()
            .map(|group| (group.name.as_str(), group.line))
            .collect();
        assert_eq!(empty, vec![("react17", 5), ("legacy", 6)]);
    }

    #[test]
    fn no_catalog_keys_returns_no_catalogs() {
        let yaml = "packages:\n  - 'packages/*'\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert!(data.catalogs.is_empty());
    }

    #[test]
    fn malformed_yaml_returns_no_catalogs() {
        let yaml = "{this is\nnot: valid: yaml: at: all";
        let data = parse_pnpm_catalog_data(yaml);
        assert!(data.catalogs.is_empty());
    }

    #[test]
    fn empty_input_returns_no_catalogs() {
        let data = parse_pnpm_catalog_data("");
        assert!(data.catalogs.is_empty());
    }

    #[test]
    fn handles_object_form_entries() {
        let yaml = "catalog:\n  react:\n    specifier: ^18.2.0\n  vue: ^3.4.0\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs[0].entries.len(), 2);
        let names: Vec<_> = data.catalogs[0]
            .entries
            .iter()
            .map(|e| e.package_name.as_str())
            .collect();
        assert!(names.contains(&"react"));
        assert!(names.contains(&"vue"));
    }

    #[test]
    fn skips_packages_section() {
        let yaml = "packages:\n  - 'apps/*'\n  - 'libs/*'\ncatalog:\n  react: ^18\n";
        let data = parse_pnpm_catalog_data(yaml);
        assert_eq!(data.catalogs.len(), 1);
        assert_eq!(data.catalogs[0].entries[0].line, 5);
    }

    #[test]
    fn strip_inline_comment_preserves_quoted_hash() {
        assert_eq!(strip_inline_comment("foo: \"a#b\" # tail"), "foo: \"a#b\"");
        assert_eq!(strip_inline_comment("# top-level"), "");
        assert_eq!(strip_inline_comment("plain: value"), "plain: value");
    }

    #[test]
    fn parse_key_handles_simple_and_quoted() {
        assert_eq!(parse_key("react: ^18"), Some("react".to_string()));
        assert_eq!(
            parse_key("\"@scope/lib\": ^1"),
            Some("@scope/lib".to_string())
        );
        assert_eq!(parse_key("'pkg': ^2"), Some("pkg".to_string()));
        assert_eq!(parse_key("- item"), None);
        assert_eq!(parse_key(""), None);
    }
}
