//! Project-local agent prompt layer.
//!
//! Reads a fixed set of orientation files from the project root and concatenates
//! them into a single bundle that is appended to every coder's system prompt at
//! spawn time.  This eliminates the cold-start tax where agents spent early turns
//! reading files that could have been preloaded.
//!
//! Files bundled (in order):
//! - `CLAUDE.md`
//! - `.huskies/README.md`
//! - `.huskies/specs/00_CONTEXT.md`
//! - `.huskies/AGENT.md`
//!
//! `STACK.md` and `.huskies/source-map.json` are intentionally excluded — they
//! are large and change often; agents should grep on demand instead.  Earlier
//! versions of this bundle inlined the source map, which ballooned the orientation
//! to ~96 KB and drowned out the workflow rules in AGENT.md; the file is still
//! kept on disk for the merge-time `source-map-check` doc-coverage gate.
//!
//! Behaviour contract:
//! - Files that are missing or empty are skipped silently (no error, no section).
//! - If all files are absent or empty, `None` is returned and agents spawn normally.
//! - A single INFO-level log line is emitted with the total byte count and the
//!   list of files that were actually included.
//! - Files are read fresh on every agent spawn — no caching.

use std::path::Path;

/// Files to bundle into the orientation prompt, in order.
/// Paths are relative to the project root.
const ORIENTATION_FILES: &[&str] = &[
    "CLAUDE.md",
    ".huskies/README.md",
    ".huskies/specs/00_CONTEXT.md",
    ".huskies/AGENT.md",
];

/// Attempt to load the project-local agent prompt by concatenating orientation
/// files from the project root.
///
/// Returns `Some(bundle)` when at least one file exists and is non-empty, with
/// sections separated by `=== <file> ===` delimiters.  Returns `None` when all
/// files are absent or empty.  Never returns an error.
pub fn read_project_local_prompt(project_root: &Path) -> Option<String> {
    let mut sections: Vec<(&str, String)> = Vec::new();

    for &rel_path in ORIENTATION_FILES {
        let abs_path = project_root.join(rel_path);
        let Ok(content) = std::fs::read_to_string(&abs_path) else {
            continue;
        };
        let trimmed = content.trim();
        if trimmed.is_empty() {
            continue;
        }
        sections.push((rel_path, trimmed.to_string()));
    }

    if sections.is_empty() {
        return None;
    }

    let included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect();
    let mut bundle = String::new();
    for (i, (name, content)) in sections.iter().enumerate() {
        if i > 0 {
            bundle.push('\n');
        }
        bundle.push_str(&format!("=== {name} ===\n"));
        bundle.push_str(content);
    }

    crate::slog!(
        "[agents] orientation bundle: {} bytes, files: [{}]",
        bundle.len(),
        included_files.join(", ")
    );

    Some(bundle)
}

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

    fn write_file(base: &Path, rel: &str, content: &str) {
        let path = base.join(rel);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(path, content).unwrap();
    }

    // ── Legacy tests (kept passing) ──────────────────────────────────────────

    #[test]
    fn returns_none_when_file_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let result = read_project_local_prompt(tmp.path());
        assert!(result.is_none(), "all files missing must return None");
    }

    #[test]
    fn returns_none_when_file_empty() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), ".huskies/AGENT.md", "");
        let result = read_project_local_prompt(tmp.path());
        assert!(
            result.is_none(),
            "empty file must return None when others absent"
        );
    }

    #[test]
    fn returns_none_when_file_whitespace_only() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), ".huskies/AGENT.md", "   \n\n  ");
        let result = read_project_local_prompt(tmp.path());
        assert!(result.is_none(), "whitespace-only file must return None");
    }

    #[test]
    fn returns_content_when_file_non_empty() {
        let tmp = tempfile::tempdir().unwrap();
        let marker = "DISTINCTIVE_MARKER_XYZ42";
        write_file(
            tmp.path(),
            ".huskies/AGENT.md",
            &format!("# Hints\n{marker}\n"),
        );
        let result = read_project_local_prompt(tmp.path());
        assert!(result.is_some(), "non-empty file must return Some");
        let content = result.unwrap();
        assert!(
            content.contains(marker),
            "returned content must include the marker: {content}"
        );
    }

    #[test]
    fn appended_to_prompt_integration() {
        let tmp_with = tempfile::tempdir().unwrap();
        let marker = "INTEGRATION_MARKER_601";
        write_file(tmp_with.path(), ".huskies/AGENT.md", marker);

        let base_prompt = "You are a coder agent.".to_string();
        let local = read_project_local_prompt(tmp_with.path());
        let effective = match local {
            Some(ref extra) => format!("{base_prompt}\n\n{extra}"),
            None => base_prompt.clone(),
        };
        assert!(
            effective.contains(marker),
            "marker must appear in effective prompt when file present: {effective}"
        );

        let tmp_without = tempfile::tempdir().unwrap();
        let local2 = read_project_local_prompt(tmp_without.path());
        assert!(local2.is_none(), "no marker when file absent");
        let effective2 = match local2 {
            Some(ref extra) => format!("{base_prompt}\n\n{extra}"),
            None => base_prompt.clone(),
        };
        assert!(
            !effective2.contains(marker),
            "marker must NOT appear in effective prompt when file absent: {effective2}"
        );
    }

    // ── New tests for four-file bundle behaviour ──────────────────────────────

    #[test]
    fn all_four_files_present_concatenated() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), "CLAUDE.md", "claude content");
        write_file(tmp.path(), ".huskies/README.md", "readme content");
        write_file(
            tmp.path(),
            ".huskies/specs/00_CONTEXT.md",
            "context content",
        );
        write_file(tmp.path(), ".huskies/AGENT.md", "agent content");

        let result = read_project_local_prompt(tmp.path()).unwrap();
        assert!(
            result.contains("claude content"),
            "must include CLAUDE.md: {result}"
        );
        assert!(
            result.contains("readme content"),
            "must include README.md: {result}"
        );
        assert!(
            result.contains("context content"),
            "must include 00_CONTEXT.md: {result}"
        );
        assert!(
            result.contains("agent content"),
            "must include AGENT.md: {result}"
        );
    }

    #[test]
    fn some_files_missing_only_present_in_bundle() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), "CLAUDE.md", "claude only");
        // README.md, 00_CONTEXT.md, AGENT.md absent

        let result = read_project_local_prompt(tmp.path()).unwrap();
        assert!(
            result.contains("claude only"),
            "must include CLAUDE.md: {result}"
        );
        assert!(
            !result.contains("=== .huskies/README.md ==="),
            "absent README must not have a section delimiter: {result}"
        );
    }

    #[test]
    fn all_files_missing_returns_none() {
        let tmp = tempfile::tempdir().unwrap();
        let result = read_project_local_prompt(tmp.path());
        assert!(result.is_none(), "all absent must return None");
    }

    #[test]
    fn section_delimiters_appear_between_files() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), "CLAUDE.md", "first");
        write_file(tmp.path(), ".huskies/AGENT.md", "last");

        let result = read_project_local_prompt(tmp.path()).unwrap();
        assert!(
            result.contains("=== CLAUDE.md ==="),
            "must have CLAUDE.md delimiter: {result}"
        );
        assert!(
            result.contains("=== .huskies/AGENT.md ==="),
            "must have AGENT.md delimiter: {result}"
        );
        // Delimiter for CLAUDE.md must appear before its content
        let claude_delim = result.find("=== CLAUDE.md ===").unwrap();
        let first_content = result.find("first").unwrap();
        assert!(
            claude_delim < first_content,
            "delimiter must precede content"
        );
    }

    #[test]
    fn stack_md_not_included() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), "CLAUDE.md", "hello");
        write_file(tmp.path(), ".huskies/specs/tech/STACK.md", "stack content");

        let result = read_project_local_prompt(tmp.path()).unwrap();
        assert!(
            !result.contains("stack content"),
            "STACK.md must not appear in bundle: {result}"
        );
    }

    // ── source-map.json must NOT be inlined into the bundle ──────────────────
    // The file is kept on disk for the merge-time source-map-check gate, but
    // inlining it into every agent spawn ballooned the orientation past 96 KB
    // and drowned out the workflow rules in AGENT.md.

    #[test]
    fn source_map_not_included_even_when_present() {
        let tmp = tempfile::tempdir().unwrap();
        write_file(tmp.path(), ".huskies/AGENT.md", "agent content");
        write_file(
            tmp.path(),
            ".huskies/source-map.json",
            r#"{"src/lib.rs": {"symbols": ["foo"]}}"#,
        );

        let result = read_project_local_prompt(tmp.path()).unwrap();
        assert!(
            !result.contains("=== .huskies/source-map.json ==="),
            "source-map must not appear as an orientation section: {result}"
        );
        assert!(
            !result.contains("src/lib.rs"),
            "source-map content must not be inlined: {result}"
        );
    }
}
