use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Component;
use std::path::Path;

use crate::model::SkillLoadOutcome;
use crate::model::SkillMetadata;
use codex_otel::SessionTelemetry;
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC;
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;

const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100;
const APPROX_BYTES_PER_TOKEN: usize = 4;
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
    "Exceeded skills context budget. All skill descriptions were removed and";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.";
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###;
pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###;

pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[String]) -> String {
    let mut lines: Vec<String> = Vec::new();
    lines.push("## Skills".to_string());
    if skill_root_lines.is_empty() {
        lines.push(SKILLS_INTRO_WITH_ABSOLUTE_PATHS.to_string());
    } else {
        lines.push(SKILLS_INTRO_WITH_ALIASES.to_string());
        lines.push("### Skill roots".to_string());
        lines.extend(skill_root_lines.iter().cloned());
    }
    lines.push("### Available skills".to_string());
    lines.extend(skill_lines.iter().cloned());

    lines.push("### How to use skills".to_string());
    let how_to_use = if skill_root_lines.is_empty() {
        SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS
    } else {
        SKILLS_HOW_TO_USE_WITH_ALIASES
    };
    lines.push(how_to_use.to_string());

    format!("\n{}\n", lines.join("\n"))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillMetadataBudget {
    Tokens(usize),
    Characters(usize),
}

impl SkillMetadataBudget {
    fn limit(self) -> usize {
        match self {
            Self::Tokens(limit) | Self::Characters(limit) => limit,
        }
    }

    fn cost(self, text: &str) -> usize {
        match self {
            Self::Tokens(_) => approx_token_count(text),
            Self::Characters(_) => text.chars().count(),
        }
    }

    fn cost_from_counts(self, chars: usize, bytes: usize) -> usize {
        match self {
            Self::Tokens(_) => approx_token_count_from_bytes(bytes),
            Self::Characters(_) => chars,
        }
    }
}

fn approx_token_count_from_bytes(bytes: usize) -> usize {
    bytes.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRenderReport {
    pub total_count: usize,
    pub included_count: usize,
    pub omitted_count: usize,
    pub truncated_description_chars: usize,
    pub truncated_description_count: usize,
}

#[derive(Clone, Copy)]
pub enum SkillRenderSideEffects<'a> {
    None,
    ThreadStart {
        session_telemetry: &'a SessionTelemetry,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableSkills {
    pub skill_root_lines: Vec<String>,
    pub skill_lines: Vec<String>,
    pub report: SkillRenderReport,
    pub warning_message: Option<String>,
}

pub fn default_skill_metadata_budget(context_window: Option<i64>) -> SkillMetadataBudget {
    context_window
        .and_then(|window| usize::try_from(window).ok())
        .filter(|window| *window > 0)
        .map(|window| {
            SkillMetadataBudget::Tokens(
                window
                    .saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT)
                    .saturating_div(100)
                    .max(1),
            )
        })
        .unwrap_or(SkillMetadataBudget::Characters(
            DEFAULT_SKILL_METADATA_CHAR_BUDGET,
        ))
}

pub fn build_available_skills(
    outcome: &SkillLoadOutcome,
    budget: SkillMetadataBudget,
    side_effects: SkillRenderSideEffects<'_>,
) -> Option<AvailableSkills> {
    let skills = outcome.allowed_skills_for_implicit_invocation();
    if skills.is_empty() {
        record_skill_render_side_effects(
            side_effects,
            /*total_count*/ 0,
            /*included_count*/ 0,
            /*omitted_count*/ 0,
            /*truncated_description_chars*/ 0,
        );
        return None;
    }

    let absolute_lines = ordered_absolute_skill_lines(&skills);
    let absolute = build_available_skills_from_lines(
        absolute_lines,
        skills.len(),
        budget,
        SkillPathAliases::default(),
    )?;

    let selected =
        if absolute.report.omitted_count == 0 && absolute.report.truncated_description_chars == 0 {
            absolute
        } else if let Some(aliased) = build_aliased_available_skills(outcome, &skills, budget) {
            if aliased_render_is_better(&aliased, &absolute, budget) {
                aliased
            } else {
                absolute
            }
        } else {
            absolute
        };

    record_available_skills_side_effects(&selected, budget, side_effects);
    Some(selected)
}

fn build_available_skills_from_lines(
    skill_lines: Vec<SkillLine<'_>>,
    total_count: usize,
    budget: SkillMetadataBudget,
    path_aliases: SkillPathAliases,
) -> Option<AvailableSkills> {
    if total_count == 0 {
        return None;
    }

    let (skill_lines, report) = render_skill_lines_from_lines(skill_lines, total_count, budget);
    let warning_message = if report.omitted_count > 0 {
        let skill_word = if report.omitted_count == 1 {
            "skill"
        } else {
            "skills"
        };
        let verb = if report.omitted_count == 1 {
            "was"
        } else {
            "were"
        };
        Some(format!(
            "{} {} additional {} {} not included in the model-visible skills list.",
            budget_warning_prefix(budget, SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX),
            report.omitted_count,
            skill_word,
            verb
        ))
    } else if report.average_truncated_description_chars()
        > SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS
    {
        Some(
            match budget {
                SkillMetadataBudget::Tokens(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT,
                SkillMetadataBudget::Characters(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING,
            }
            .to_string(),
        )
    } else {
        None
    };
    let available = AvailableSkills {
        skill_root_lines: path_aliases.skill_root_lines,
        skill_lines,
        report,
        warning_message,
    };
    Some(available)
}

fn record_available_skills_side_effects(
    available: &AvailableSkills,
    budget: SkillMetadataBudget,
    side_effects: SkillRenderSideEffects<'_>,
) {
    record_skill_render_side_effects(
        side_effects,
        available.report.total_count,
        available.report.included_count,
        available.report.omitted_count,
        available.report.truncated_description_chars,
    );
    if available.report.omitted_count > 0 || available.report.truncated_description_chars > 0 {
        tracing::info!(
            budget_limit = budget.limit(),
            total_skills = available.report.total_count,
            included_skills = available.report.included_count,
            omitted_skills = available.report.omitted_count,
            truncated_description_chars_per_skill =
                available.report.average_truncated_description_chars(),
            truncated_skill_descriptions = available.report.truncated_description_count,
            "truncated skill metadata to fit skills context budget"
        );
    }
}

fn budget_warning_prefix(budget: SkillMetadataBudget, prefix: &str) -> String {
    match budget {
        SkillMetadataBudget::Tokens(_) => prefix.replacen(
            "Exceeded skills context budget.",
            "Exceeded skills context budget of 2%.",
            1,
        ),
        SkillMetadataBudget::Characters(_) => prefix.to_string(),
    }
}

fn record_skill_render_side_effects(
    side_effects: SkillRenderSideEffects<'_>,
    total_count: usize,
    included_count: usize,
    omitted_count: usize,
    truncated_description_chars: usize,
) {
    match side_effects {
        SkillRenderSideEffects::None => {}
        SkillRenderSideEffects::ThreadStart { session_telemetry } => {
            session_telemetry.histogram(
                THREAD_SKILLS_ENABLED_TOTAL_METRIC,
                i64::try_from(total_count).unwrap_or(i64::MAX),
                &[],
            );
            session_telemetry.histogram(
                THREAD_SKILLS_KEPT_TOTAL_METRIC,
                i64::try_from(included_count).unwrap_or(i64::MAX),
                &[],
            );
            session_telemetry.histogram(
                THREAD_SKILLS_TRUNCATED_METRIC,
                if omitted_count > 0 { 1 } else { 0 },
                &[],
            );
            session_telemetry.histogram(
                THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC,
                i64::try_from(truncated_description_chars).unwrap_or(i64::MAX),
                &[],
            );
        }
    }
}

fn render_skill_lines_from_lines(
    skill_lines: Vec<SkillLine<'_>>,
    total_count: usize,
    budget: SkillMetadataBudget,
) -> (Vec<String>, SkillRenderReport) {
    let full_cost = skill_lines.iter().fold(0usize, |used, line| {
        used.saturating_add(line.full_cost(budget))
    });
    if full_cost <= budget.limit() {
        let included = skill_lines
            .iter()
            .map(SkillLine::render_full)
            .collect::<Vec<_>>();

        return (
            included,
            skill_render_report(
                total_count,
                /*included_count*/ skill_lines.len(),
                /*omitted_count*/ 0,
                /*truncated_description_chars*/ 0,
                /*truncated_description_count*/ 0,
            ),
        );
    }

    let minimum_cost = skill_lines.iter().fold(0usize, |used, line| {
        used.saturating_add(line.minimum_cost(budget))
    });
    if minimum_cost <= budget.limit() {
        let rendered = render_lines_with_description_budget(
            budget,
            &skill_lines,
            budget.limit().saturating_sub(minimum_cost),
        );
        let (truncated_description_chars, truncated_description_count) =
            sum_description_truncation(&rendered);
        let included = rendered
            .into_iter()
            .map(|rendered| rendered.line)
            .collect::<Vec<_>>();

        return (
            included,
            skill_render_report(
                total_count,
                /*included_count*/ skill_lines.len(),
                /*omitted_count*/ 0,
                truncated_description_chars,
                truncated_description_count,
            ),
        );
    }

    render_minimum_skill_lines_until_budget(budget, skill_lines, total_count)
}

fn render_minimum_skill_lines_until_budget(
    budget: SkillMetadataBudget,
    skill_lines: Vec<SkillLine<'_>>,
    total_count: usize,
) -> (Vec<String>, SkillRenderReport) {
    let mut included = Vec::new();
    let mut used = 0usize;
    let mut omitted_count = 0usize;
    let mut truncated_description_chars = 0usize;
    let mut truncated_description_count = 0usize;
    for line in skill_lines {
        let line_cost = line.minimum_cost(budget);
        let description_char_count = line.description_char_count();
        if used.saturating_add(line_cost) <= budget.limit() {
            used = used.saturating_add(line_cost);
            included.push(line.render_minimum());
        } else {
            omitted_count = omitted_count.saturating_add(1);
        }

        truncated_description_chars =
            truncated_description_chars.saturating_add(description_char_count);
        if description_char_count > 0 {
            truncated_description_count = truncated_description_count.saturating_add(1);
        }
    }

    let report = skill_render_report(
        total_count,
        included.len(),
        omitted_count,
        truncated_description_chars,
        truncated_description_count,
    );

    (included, report)
}

fn skill_render_report(
    total_count: usize,
    included_count: usize,
    omitted_count: usize,
    truncated_description_chars: usize,
    truncated_description_count: usize,
) -> SkillRenderReport {
    SkillRenderReport {
        total_count,
        included_count,
        omitted_count,
        truncated_description_chars,
        truncated_description_count,
    }
}

impl SkillRenderReport {
    fn average_truncated_description_chars(&self) -> usize {
        if self.total_count == 0 || self.truncated_description_chars == 0 {
            return 0;
        }

        self.truncated_description_chars
            .saturating_add(self.total_count.saturating_sub(1))
            / self.total_count
    }
}

struct SkillLine<'a> {
    name: &'a str,
    description: &'a str,
    path: String,
}

struct RenderedSkillLine {
    line: String,
    truncated_chars: usize,
}

struct DescriptionBudgetLine<'a> {
    line: &'a SkillLine<'a>,
    description_char_count: usize,
    extra_costs: Vec<usize>,
}

fn sum_description_truncation(rendered: &[RenderedSkillLine]) -> (usize, usize) {
    rendered
        .iter()
        .fold((0usize, 0usize), |(chars, count), line| {
            if line.truncated_chars == 0 {
                (chars, count)
            } else {
                (
                    chars.saturating_add(line.truncated_chars),
                    count.saturating_add(1),
                )
            }
        })
}

impl<'a> SkillLine<'a> {
    fn new(skill: &'a SkillMetadata) -> Self {
        Self::with_path(
            skill,
            skill.path_to_skills_md.to_string_lossy().replace('\\', "/"),
        )
    }

    fn with_path(skill: &'a SkillMetadata, path: String) -> Self {
        Self {
            name: skill.name.as_str(),
            description: skill.description.as_str(),
            path,
        }
    }

    fn full_cost(&self, budget: SkillMetadataBudget) -> usize {
        line_cost(budget, &self.render_full())
    }

    fn minimum_cost(&self, budget: SkillMetadataBudget) -> usize {
        line_cost(budget, &self.render_minimum())
    }

    fn description_char_count(&self) -> usize {
        self.description.chars().count()
    }

    fn render_full(&self) -> String {
        self.render_with_description(self.description)
    }

    fn render_minimum(&self) -> String {
        self.render_with_description("")
    }

    fn rendered_description_prefix_len(&self, description_chars: usize) -> usize {
        self.description
            .char_indices()
            .nth(description_chars)
            .map_or(self.description.len(), |(idx, _)| idx)
    }

    fn render_with_description_chars(&self, description_chars: usize) -> String {
        if description_chars == 0 {
            format!("- {}: (file: {})", self.name, self.path)
        } else {
            let end = self.rendered_description_prefix_len(description_chars);
            let description = &self.description[..end];
            format!("- {}: {} (file: {})", self.name, description, self.path)
        }
    }

    fn render_with_description(&self, description: &str) -> String {
        if description.is_empty() {
            format!("- {}: (file: {})", self.name, self.path)
        } else {
            format!("- {}: {} (file: {})", self.name, description, self.path)
        }
    }
}

impl<'a> DescriptionBudgetLine<'a> {
    fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self {
        let minimum_line = line.render_minimum();
        let minimum_chars = minimum_line.chars().count().saturating_add(1);
        let minimum_bytes = minimum_line.len().saturating_add(1);
        let minimum_cost = budget.cost_from_counts(minimum_chars, minimum_bytes);

        let description_char_count = line.description_char_count();
        let mut extra_costs = Vec::with_capacity(description_char_count.saturating_add(1));
        extra_costs.push(0);

        let mut prefix_chars = 0usize;
        let mut prefix_bytes = 0usize;
        for ch in line.description.chars() {
            prefix_chars = prefix_chars.saturating_add(1);
            prefix_bytes = prefix_bytes.saturating_add(ch.len_utf8());
            let rendered_chars = minimum_chars.saturating_add(prefix_chars).saturating_add(1);
            let rendered_bytes = minimum_bytes.saturating_add(prefix_bytes).saturating_add(1);
            let cost = budget
                .cost_from_counts(rendered_chars, rendered_bytes)
                .saturating_sub(minimum_cost);
            extra_costs.push(cost);
        }

        Self {
            line,
            description_char_count,
            extra_costs,
        }
    }
}

fn line_cost(budget: SkillMetadataBudget, line: &str) -> usize {
    budget.cost(&format!("{line}\n"))
}

fn lines_cost(budget: SkillMetadataBudget, lines: &[String]) -> usize {
    lines.iter().fold(0usize, |used, line| {
        used.saturating_add(line_cost(budget, line))
    })
}

fn render_lines_with_description_budget(
    budget: SkillMetadataBudget,
    skill_lines: &[SkillLine<'_>],
    limit: usize,
) -> Vec<RenderedSkillLine> {
    let budget_lines = skill_lines
        .iter()
        .map(|line| DescriptionBudgetLine::new(line, budget))
        .collect::<Vec<_>>();
    let mut char_allocations = vec![0usize; budget_lines.len()];
    let mut current_extra_costs = vec![0usize; budget_lines.len()];
    let mut remaining = limit;

    // Distribute description space one character at a time across skills.
    // Short descriptions naturally drop out, so their unused share can go to
    // longer descriptions instead of being stranded in a fixed per-skill quota.
    loop {
        let mut changed = false;
        for (index, line) in budget_lines.iter().enumerate() {
            if char_allocations[index] >= line.description_char_count {
                continue;
            }

            let current_cost = current_extra_costs[index];
            let next_chars = char_allocations[index].saturating_add(1);
            let next_cost = line.extra_costs[next_chars];
            let delta = next_cost.saturating_sub(current_cost);
            if delta <= remaining {
                char_allocations[index] = next_chars;
                current_extra_costs[index] = next_cost;
                remaining = remaining.saturating_sub(delta);
                changed = true;
            }
        }

        if !changed {
            break;
        }
    }

    budget_lines
        .iter()
        .zip(char_allocations)
        .map(|(line, description_chars)| {
            let truncated_chars = line
                .description_char_count
                .saturating_sub(description_chars);
            RenderedSkillLine {
                line: line.line.render_with_description_chars(description_chars),
                truncated_chars,
            }
        })
        .collect()
}

fn build_aliased_available_skills(
    outcome: &SkillLoadOutcome,
    skills: &[SkillMetadata],
    budget: SkillMetadataBudget,
) -> Option<AvailableSkills> {
    let plan = build_alias_plan(outcome, skills, budget)?;
    if plan.table_cost >= budget.limit() {
        return None;
    }

    let adjusted_limit = budget.limit().saturating_sub(plan.table_cost);
    let adjusted_budget = match budget {
        SkillMetadataBudget::Tokens(_) => SkillMetadataBudget::Tokens(adjusted_limit),
        SkillMetadataBudget::Characters(_) => SkillMetadataBudget::Characters(adjusted_limit),
    };
    let ordered_skills = ordered_skills_for_budget(skills);
    let skill_lines = ordered_skills
        .into_iter()
        .map(|skill| SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan)))
        .collect::<Vec<_>>();
    build_available_skills_from_lines(skill_lines, skills.len(), adjusted_budget, plan.aliases)
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct SkillPathAliases {
    skill_root_lines: Vec<String>,
}

struct AliasPlan {
    aliases: SkillPathAliases,
    root_aliases: HashMap<AbsolutePathBuf, String>,
    alias_root_by_path: HashMap<AbsolutePathBuf, AbsolutePathBuf>,
    table_cost: usize,
}

fn build_alias_plan(
    outcome: &SkillLoadOutcome,
    skills: &[SkillMetadata],
    budget: SkillMetadataBudget,
) -> Option<AliasPlan> {
    let skill_paths = skills
        .iter()
        .map(|skill| skill.path_to_skills_md.clone())
        .collect::<HashSet<_>>();
    let skill_root_by_path = outcome
        .skill_root_by_path
        .iter()
        .filter(|(path, _)| skill_paths.contains(*path))
        .map(|(path, root)| (path.clone(), root.clone()))
        .collect::<HashMap<_, _>>();
    let used_roots = outcome
        .skill_roots
        .iter()
        .filter(|root| {
            skill_root_by_path
                .values()
                .any(|skill_root| skill_root == *root)
        })
        .cloned()
        .collect::<Vec<_>>();
    if used_roots.is_empty() {
        return None;
    }

    let plugin_version_skill_counts =
        plugin_version_skill_counts_for_skill_roots(skill_root_by_path.values());
    let alias_root_by_skill_root = used_roots
        .iter()
        .map(|root| {
            (
                root.clone(),
                alias_root_for_skill_root(root, &plugin_version_skill_counts),
            )
        })
        .collect::<HashMap<_, _>>();
    let alias_roots = ordered_alias_roots(&used_roots, &alias_root_by_skill_root)?;
    let root_aliases = alias_roots
        .iter()
        .enumerate()
        .map(|(index, alias_root)| (alias_root.clone(), format!("r{index}")))
        .collect::<HashMap<_, _>>();
    let alias_root_by_path = skill_root_by_path
        .iter()
        .filter_map(|(path, skill_root)| {
            alias_root_by_skill_root
                .get(skill_root)
                .map(|alias_root| (path.clone(), alias_root.clone()))
        })
        .collect::<HashMap<_, _>>();
    let skill_root_lines = build_skill_root_lines(&alias_roots);
    let table_cost = aliased_metadata_overhead_cost(budget, &skill_root_lines);

    Some(AliasPlan {
        aliases: SkillPathAliases { skill_root_lines },
        root_aliases,
        alias_root_by_path,
        table_cost,
    })
}

fn ordered_alias_roots(
    used_roots: &[AbsolutePathBuf],
    alias_root_by_skill_root: &HashMap<AbsolutePathBuf, AbsolutePathBuf>,
) -> Option<Vec<AbsolutePathBuf>> {
    let mut seen = HashSet::new();
    let mut alias_roots = Vec::new();
    for root in used_roots {
        let alias_root = alias_root_by_skill_root.get(root)?.clone();
        if seen.insert(alias_root.clone()) {
            alias_roots.push(alias_root);
        }
    }
    Some(alias_roots)
}

fn alias_root_for_skill_root(
    root: &AbsolutePathBuf,
    plugin_version_skill_counts: &HashMap<AbsolutePathBuf, usize>,
) -> AbsolutePathBuf {
    let Some(plugin_version_base) = plugin_version_base(root.as_path()) else {
        return root.clone();
    };
    let skill_count = plugin_version_skill_counts
        .get(&plugin_version_base)
        .copied()
        .unwrap_or_default();
    if skill_count > 1 {
        root.clone()
    } else {
        plugin_marketplace_base(root.as_path()).unwrap_or_else(|| root.clone())
    }
}

fn plugin_version_skill_counts_for_skill_roots<'a>(
    skill_roots: impl Iterator<Item = &'a AbsolutePathBuf>,
) -> HashMap<AbsolutePathBuf, usize> {
    let mut counts = HashMap::new();
    for root in skill_roots {
        if let Some(plugin_version_base) = plugin_version_base(root.as_path()) {
            let count = counts.entry(plugin_version_base).or_insert(0usize);
            *count = count.saturating_add(1);
        }
    }
    counts
}

fn aliased_metadata_overhead_cost(
    budget: SkillMetadataBudget,
    skill_root_lines: &[String],
) -> usize {
    let empty_skill_lines: &[String] = &[];
    let absolute_body = render_available_skills_body(&[], empty_skill_lines);
    let aliased_body = render_available_skills_body(skill_root_lines, empty_skill_lines);
    budget
        .cost(&aliased_body)
        .saturating_sub(budget.cost(&absolute_body))
}

fn build_skill_root_lines(roots: &[AbsolutePathBuf]) -> Vec<String> {
    roots
        .iter()
        .enumerate()
        .map(|(index, root)| {
            let root_str = root.to_string_lossy().replace('\\', "/");
            format!("- `r{index}` = `{root_str}`")
        })
        .collect()
}

fn plugin_marketplace_base(path: &Path) -> Option<AbsolutePathBuf> {
    let mut candidate = path;
    while let Some(parent) = candidate.parent() {
        if parent.file_name()?.to_str()? == "cache"
            && parent.parent()?.file_name()?.to_str()? == "plugins"
        {
            return AbsolutePathBuf::from_absolute_path(candidate).ok();
        }
        candidate = parent;
    }
    None
}

fn plugin_version_base(path: &Path) -> Option<AbsolutePathBuf> {
    let marketplace_base = plugin_marketplace_base(path)?;
    let mut relative_components = path
        .strip_prefix(marketplace_base.as_path())
        .ok()?
        .components();
    let plugin = match relative_components.next()? {
        Component::Normal(plugin) => plugin,
        _ => return None,
    };
    let version = match relative_components.next()? {
        Component::Normal(version) => version,
        _ => return None,
    };
    AbsolutePathBuf::from_absolute_path(marketplace_base.join(plugin).join(version)).ok()
}

fn render_skill_path_with_aliases(skill: &SkillMetadata, plan: &AliasPlan) -> String {
    outcome_relative_skill_path(skill, plan)
        .unwrap_or_else(|| skill.path_to_skills_md.to_string_lossy().replace('\\', "/"))
}

fn outcome_relative_skill_path(skill: &SkillMetadata, plan: &AliasPlan) -> Option<String> {
    let alias_root = plan.alias_root_by_path.get(&skill.path_to_skills_md)?;
    let alias = plan.root_aliases.get(alias_root)?;
    let relative_path = skill
        .path_to_skills_md
        .as_path()
        .strip_prefix(alias_root.as_path())
        .ok()?;
    let relative_path = relative_path.to_string_lossy().replace('\\', "/");
    Some(format!("{alias}/{relative_path}"))
}

fn aliased_render_is_better(
    aliased: &AvailableSkills,
    absolute: &AvailableSkills,
    budget: SkillMetadataBudget,
) -> bool {
    if aliased.report.included_count != absolute.report.included_count {
        return aliased.report.included_count > absolute.report.included_count;
    }
    if aliased.report.truncated_description_chars != absolute.report.truncated_description_chars {
        return aliased.report.truncated_description_chars
            < absolute.report.truncated_description_chars;
    }
    available_skills_cost(budget, aliased) < available_skills_cost(budget, absolute)
}

fn available_skills_cost(budget: SkillMetadataBudget, available: &AvailableSkills) -> usize {
    let metadata_cost = if available.skill_root_lines.is_empty() {
        0
    } else {
        aliased_metadata_overhead_cost(budget, &available.skill_root_lines)
    };
    metadata_cost.saturating_add(lines_cost(budget, &available.skill_lines))
}

fn ordered_absolute_skill_lines(skills: &[SkillMetadata]) -> Vec<SkillLine<'_>> {
    ordered_skills_for_budget(skills)
        .into_iter()
        .map(SkillLine::new)
        .collect()
}

fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> {
    let mut ordered = skills.iter().collect::<Vec<_>>();
    ordered.sort_by(|a, b| {
        prompt_scope_rank(a.scope)
            .cmp(&prompt_scope_rank(b.scope))
            .then_with(|| a.name.cmp(&b.name))
            .then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md))
    });
    ordered
}

fn prompt_scope_rank(scope: SkillScope) -> u8 {
    match scope {
        SkillScope::System => 0,
        SkillScope::Admin => 1,
        SkillScope::Repo => 2,
        SkillScope::User => 3,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::sync::Arc;

    use codex_utils_absolute_path::test_support::PathBufExt;
    use codex_utils_absolute_path::test_support::test_path_buf;
    use pretty_assertions::assert_eq;

    fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata {
        SkillMetadata {
            name: name.to_string(),
            description: "desc".to_string(),
            short_description: None,
            interface: None,
            dependencies: None,
            policy: None,
            path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
            scope,
            plugin_id: None,
        }
    }

    fn make_skill_with_description(
        name: &str,
        scope: SkillScope,
        description: &str,
    ) -> SkillMetadata {
        let mut skill = make_skill(name, scope);
        skill.description = description.to_string();
        skill
    }

    fn expected_skill_line(skill: &SkillMetadata, description: &str) -> String {
        SkillLine::new(skill).render_with_description(description)
    }

    fn normalized_path(path: &AbsolutePathBuf) -> String {
        path.to_string_lossy().replace('\\', "/")
    }

    fn outcome_with_roots(
        skills: Vec<SkillMetadata>,
        roots: Vec<AbsolutePathBuf>,
    ) -> SkillLoadOutcome {
        let skill_root_by_path = skills
            .iter()
            .filter_map(|skill| {
                roots
                    .iter()
                    .find(|root| {
                        skill
                            .path_to_skills_md
                            .as_path()
                            .starts_with(root.as_path())
                    })
                    .map(|root| (skill.path_to_skills_md.clone(), root.clone()))
            })
            .collect::<HashMap<_, _>>();
        SkillLoadOutcome {
            skills,
            skill_roots: roots,
            skill_root_by_path: Arc::new(skill_root_by_path),
            ..Default::default()
        }
    }

    fn build_available_skills_from_metadata(
        skills: &[SkillMetadata],
        budget: SkillMetadataBudget,
    ) -> Option<AvailableSkills> {
        build_available_skills_from_lines(
            ordered_absolute_skill_lines(skills),
            skills.len(),
            budget,
            SkillPathAliases::default(),
        )
    }

    #[test]
    fn default_budget_uses_two_percent_of_full_context_window() {
        assert_eq!(
            default_skill_metadata_budget(Some(200_000)),
            SkillMetadataBudget::Tokens(4_000)
        );
        assert_eq!(
            default_skill_metadata_budget(Some(99)),
            SkillMetadataBudget::Tokens(1)
        );
    }

    #[test]
    fn default_budget_falls_back_to_characters_without_context_window() {
        assert_eq!(
            default_skill_metadata_budget(/*context_window*/ None),
            SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
        );
        assert_eq!(
            default_skill_metadata_budget(Some(-1)),
            SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
        );
    }

    #[test]
    fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() {
        let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef");
        let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyz");
        let minimum_cost = SkillLine::new(&alpha)
            .minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
            + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
        let budget = SkillMetadataBudget::Characters(minimum_cost + 6);

        let rendered = build_available_skills_from_metadata(&[beta.clone(), alpha.clone()], budget)
            .expect("skills should render");

        assert_eq!(rendered.report.included_count, 2);
        assert_eq!(rendered.report.omitted_count, 0);
        assert_eq!(rendered.report.truncated_description_chars, 8);
        assert_eq!(rendered.warning_message, None);
        assert_eq!(
            rendered.skill_lines,
            vec![
                expected_skill_line(&alpha, "ab"),
                expected_skill_line(&beta, "uv"),
            ]
        );
    }

    #[test]
    fn budgeted_rendering_does_not_warn_when_average_description_truncation_is_within_threshold() {
        let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghij");
        let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcd");
        let minimum_cost = SkillLine::new(&alpha)
            .minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
            + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
        let budget = SkillMetadataBudget::Characters(minimum_cost + 6);

        let rendered = build_available_skills_from_metadata(&[alpha, beta], budget)
            .expect("skills should render");

        assert_eq!(rendered.report.included_count, 2);
        assert_eq!(rendered.report.omitted_count, 0);
        assert_eq!(rendered.report.truncated_description_chars, 16);
        assert_eq!(rendered.report.truncated_description_count, 2);
        assert_eq!(rendered.warning_message, None);
    }

    #[test]
    fn budgeted_rendering_warns_when_average_description_truncation_exceeds_threshold() {
        let long_description = "a".repeat(250);
        let long_skill =
            make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
        let empty_skill = make_skill_with_description("empty-skill", SkillScope::Repo, "");
        let minimum_cost = SkillLine::new(&long_skill)
            .minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
            + SkillLine::new(&empty_skill)
                .minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
        let budget = SkillMetadataBudget::Characters(minimum_cost + 49);

        let rendered = build_available_skills_from_metadata(&[long_skill, empty_skill], budget)
            .expect("skills should render");

        assert_eq!(rendered.report.total_count, 2);
        assert_eq!(rendered.report.included_count, 2);
        assert_eq!(rendered.report.omitted_count, 0);
        assert_eq!(rendered.report.truncated_description_chars, 202);
        assert_eq!(rendered.report.truncated_description_count, 1);
        assert_eq!(
            rendered.warning_message,
            Some(
                "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."
                    .to_string()
            )
        );
    }

    #[test]
    fn budgeted_rendering_token_budget_truncation_warning_mentions_two_percent() {
        let long_description = "a".repeat(1000);
        let long_skill =
            make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
        let minimum_cost =
            SkillLine::new(&long_skill).minimum_cost(SkillMetadataBudget::Tokens(usize::MAX));
        let budget = SkillMetadataBudget::Tokens(minimum_cost + 1);

        let rendered = build_available_skills_from_metadata(&[long_skill], budget)
            .expect("skills should render");

        assert_eq!(
            rendered.warning_message,
            Some(SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT.to_string())
        );
    }

    #[test]
    fn budgeted_rendering_redistributes_unused_description_budget() {
        let short = make_skill_with_description("short-skill", SkillScope::Repo, "x");
        let long = make_skill_with_description("long-skill", SkillScope::Repo, "abcdefghi");
        let minimum_cost = SkillLine::new(&short)
            .minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
            + SkillLine::new(&long).minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
        let budget = SkillMetadataBudget::Characters(minimum_cost + 11);

        let rendered = build_available_skills_from_metadata(&[short.clone(), long.clone()], budget)
            .expect("skills should render");

        assert_eq!(rendered.report.included_count, 2);
        assert_eq!(rendered.report.omitted_count, 0);
        assert_eq!(rendered.warning_message, None);
        assert_eq!(
            rendered.skill_lines,
            vec![
                expected_skill_line(&long, "abcdefgh"),
                expected_skill_line(&short, "x"),
            ]
        );
    }

    #[test]
    fn budgeted_rendering_preserves_prompt_priority_when_minimum_lines_exceed_budget() {
        let system = make_skill("system-skill", SkillScope::System);
        let user = make_skill("user-skill", SkillScope::User);
        let repo = make_skill("repo-skill", SkillScope::Repo);
        let admin = make_skill("admin-skill", SkillScope::Admin);
        let system_cost = SkillMetadataBudget::Characters(usize::MAX)
            .cost(&format!("{}\n", SkillLine::new(&system).render_minimum()));
        let admin_cost = SkillMetadataBudget::Characters(usize::MAX)
            .cost(&format!("{}\n", SkillLine::new(&admin).render_minimum()));
        let budget = SkillMetadataBudget::Characters(system_cost + admin_cost);

        let rendered = build_available_skills_from_metadata(&[system, user, repo, admin], budget)
            .expect("skills should render");

        assert_eq!(rendered.report.included_count, 2);
        assert_eq!(rendered.report.omitted_count, 2);
        assert_eq!(
            rendered.warning_message,
            Some(
                "Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
                    .to_string()
            )
        );
        let rendered_text = rendered.skill_lines.join("\n");
        assert!(rendered_text.contains("- system-skill:"));
        assert!(rendered_text.contains("- admin-skill:"));
        assert!(!rendered_text.contains("desc"));
        assert!(!rendered_text.contains("- repo-skill:"));
        assert!(!rendered_text.contains("- user-skill:"));
    }

    #[test]
    fn budgeted_rendering_keeps_scanning_after_oversized_entry() {
        let mut oversized = make_skill("oversized-system-skill", SkillScope::System);
        oversized.description = "desc ".repeat(100);
        let repo = make_skill("repo-skill", SkillScope::Repo);
        let repo_cost = SkillMetadataBudget::Characters(usize::MAX)
            .cost(&format!("{}\n", SkillLine::new(&repo).render_full()));
        let budget = SkillMetadataBudget::Characters(repo_cost);

        let rendered = build_available_skills_from_metadata(&[oversized, repo], budget)
            .expect("skills render");

        assert_eq!(rendered.report.included_count, 1);
        assert_eq!(rendered.report.omitted_count, 1);
        assert_eq!(
            rendered.warning_message,
            Some(
                "Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
                    .to_string()
            )
        );
        let rendered_text = rendered.skill_lines.join("\n");
        assert!(!rendered_text.contains("- oversized-system-skill:"));
        assert!(rendered_text.contains("- repo-skill:"));
    }

    #[test]
    fn outcome_rendering_omits_aliases_when_absolute_plan_has_no_budget_pressure() {
        let root = test_path_buf("/tmp/skills").abs();
        let alpha_path = root.join("alpha/SKILL.md");
        let beta_path = root.join("beta/SKILL.md");
        let outcome = outcome_with_roots(
            vec![
                skill_with_path("alpha-skill", &alpha_path),
                skill_with_path("beta-skill", &beta_path),
            ],
            vec![root],
        );

        let rendered = build_available_skills(
            &outcome,
            SkillMetadataBudget::Characters(usize::MAX),
            SkillRenderSideEffects::None,
        )
        .expect("skills should render");

        assert!(rendered.skill_root_lines.is_empty());
        assert_eq!(rendered.report.included_count, 2);
    }

    #[test]
    fn outcome_rendering_uses_aliases_when_they_allow_more_skills_to_fit() {
        let root = test_path_buf(
            "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix",
        )
        .abs();
        let skills = (0..12)
            .map(|index| {
                let name = format!("shared-root-skill-{index}");
                skill_with_path(&name, &root.join(format!("skill-{index}/SKILL.md")))
            })
            .collect::<Vec<_>>();
        let outcome = outcome_with_roots(skills.clone(), vec![root]);
        let absolute_minimum = skills.iter().fold(0usize, |cost, skill| {
            cost.saturating_add(
                SkillLine::new(skill).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)),
            )
        });
        let plan = build_alias_plan(
            &outcome,
            &skills,
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");
        let alias_minimum = skills.iter().fold(plan.table_cost, |cost, skill| {
            cost.saturating_add(
                SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan))
                    .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)),
            )
        });
        assert!(
            alias_minimum < absolute_minimum,
            "test fixture should make aliases cheaper"
        );

        let rendered = build_available_skills(
            &outcome,
            SkillMetadataBudget::Characters(alias_minimum),
            SkillRenderSideEffects::None,
        )
        .expect("skills should render");

        assert_eq!(rendered.report.included_count, skills.len());
        assert_eq!(rendered.report.omitted_count, 0);
        assert_eq!(
            rendered.skill_root_lines,
            vec![format!(
                "- `r0` = `{}`",
                normalized_path(
                    &test_path_buf(
                        "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix"
                    )
                    .abs()
                )
            )]
        );
        let rendered_text = rendered.skill_lines.join("\n");
        assert!(rendered_text.contains("r0/skill-0/SKILL.md"));
        assert!(rendered_text.contains("r0/skill-11/SKILL.md"));
    }

    #[test]
    fn outcome_rendering_uses_marketplace_root_for_single_skill_plugin_versions() {
        let github_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills")
                .abs();
        let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs();
        let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md"));
        let outcome = outcome_with_roots(vec![github.clone()], vec![github_root.clone()]);
        let plan = build_alias_plan(
            &outcome,
            &[github],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");

        assert_eq!(
            plan.aliases.skill_root_lines,
            vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))]
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")),
                &plan
            ),
            "r0/github/hash123/skills/gh-fix-ci/SKILL.md"
        );
    }

    #[test]
    fn outcome_rendering_uses_skill_root_for_multiple_skills_in_one_plugin_version() {
        let github_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills")
                .abs();
        let fix_ci = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md"));
        let yeet = skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md"));
        let outcome = outcome_with_roots(
            vec![fix_ci.clone(), yeet.clone()],
            vec![github_root.clone()],
        );
        let plan = build_alias_plan(
            &outcome,
            &[fix_ci, yeet],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");

        assert_eq!(
            plan.aliases.skill_root_lines,
            vec![format!("- `r0` = `{}`", normalized_path(&github_root))]
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")),
                &plan
            ),
            "r0/gh-fix-ci/SKILL.md"
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")),
                &plan
            ),
            "r0/yeet/SKILL.md"
        );
    }

    #[test]
    fn outcome_rendering_counts_plugin_version_skills_before_budget_omission() {
        let root = test_path_buf(
            "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix",
        )
        .abs();
        let alpha = skill_with_path("alpha-skill", &root.join("alpha/SKILL.md"));
        let beta = skill_with_path("beta-skill", &root.join("beta/SKILL.md"));
        let outcome = outcome_with_roots(vec![alpha.clone(), beta.clone()], vec![root.clone()]);
        let plan = build_alias_plan(
            &outcome,
            &[alpha.clone(), beta.clone()],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");
        let alpha_cost = SkillMetadataBudget::Characters(usize::MAX).cost(&format!(
            "{}\n",
            SkillLine::with_path(&alpha, render_skill_path_with_aliases(&alpha, &plan))
                .render_minimum()
        ));
        let rendered = build_aliased_available_skills(
            &outcome,
            &[alpha, beta],
            SkillMetadataBudget::Characters(plan.table_cost + alpha_cost),
        )
        .expect("skills should render");

        assert_eq!(rendered.report.included_count, 1);
        assert_eq!(
            rendered.skill_root_lines,
            vec![format!("- `r0` = `{}`", normalized_path(&root))]
        );
        assert_eq!(
            rendered.skill_lines,
            vec!["- alpha-skill: (file: r0/alpha/SKILL.md)"]
        );
    }

    #[test]
    fn outcome_rendering_uses_each_skill_root_for_multiple_roots_in_one_plugin_version() {
        let skills_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills")
                .abs();
        let extra_root = test_path_buf(
            "/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/extra-skills",
        )
        .abs();
        let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md"));
        let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md"));
        let outcome = outcome_with_roots(
            vec![fix_ci.clone(), yeet.clone()],
            vec![skills_root.clone(), extra_root.clone()],
        );
        let plan = build_alias_plan(
            &outcome,
            &[fix_ci, yeet],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");

        assert_eq!(
            plan.aliases.skill_root_lines,
            vec![
                format!("- `r0` = `{}`", normalized_path(&skills_root)),
                format!("- `r1` = `{}`", normalized_path(&extra_root)),
            ]
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")),
                &plan
            ),
            "r0/gh-fix-ci/SKILL.md"
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")),
                &plan
            ),
            "r1/yeet/SKILL.md"
        );
    }

    #[test]
    fn outcome_rendering_extracts_plugin_marketplace_root_for_multiple_plugins() {
        let github_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills")
                .abs();
        let slack_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/slack/hash456/skills")
                .abs();
        let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs();
        let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md"));
        let slack = skill_with_path(
            "slack:daily-digest",
            &slack_root.join("daily-digest/SKILL.md"),
        );
        let outcome = outcome_with_roots(
            vec![github.clone(), slack.clone()],
            vec![github_root.clone(), slack_root.clone()],
        );
        let plan = build_alias_plan(
            &outcome,
            &[github, slack],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");

        assert_eq!(
            plan.aliases.skill_root_lines,
            vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))]
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")),
                &plan
            ),
            "r0/github/hash123/skills/gh-fix-ci/SKILL.md"
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path(
                    "slack:daily-digest",
                    &slack_root.join("daily-digest/SKILL.md")
                ),
                &plan
            ),
            "r0/slack/hash456/skills/daily-digest/SKILL.md"
        );
    }

    #[test]
    fn outcome_rendering_uses_one_marketplace_root_for_multiple_plugin_versions() {
        let skills_root =
            test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills")
                .abs();
        let extra_root = test_path_buf(
            "/Users/xl/.codex/plugins/cache/openai-curated/github/hash456/extra-skills",
        )
        .abs();
        let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs();
        let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md"));
        let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md"));
        let outcome = outcome_with_roots(
            vec![fix_ci.clone(), yeet.clone()],
            vec![skills_root.clone(), extra_root.clone()],
        );
        let plan = build_alias_plan(
            &outcome,
            &[fix_ci, yeet],
            SkillMetadataBudget::Characters(usize::MAX),
        )
        .expect("alias plan should build");

        assert_eq!(
            plan.aliases.skill_root_lines,
            vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))]
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")),
                &plan
            ),
            "r0/github/hash123/skills/gh-fix-ci/SKILL.md"
        );
        assert_eq!(
            render_skill_path_with_aliases(
                &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")),
                &plan
            ),
            "r0/github/hash456/extra-skills/yeet/SKILL.md"
        );
    }

    fn skill_with_path(name: &str, path: &AbsolutePathBuf) -> SkillMetadata {
        let mut skill = make_skill(name, SkillScope::User);
        skill.path_to_skills_md = path.clone();
        skill
    }
}
