//! Note parsing and types
//!
//! Notes are developer observations with sentiment, stored in TOML and
//! indexed for semantic search.

use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;

/// Sentiment thresholds for classification
/// 0.3 chosen to separate neutral observations from significant notes:
/// - Values near 0 are neutral observations
/// - Values beyond ±0.3 indicate meaningful sentiment (warning/pattern)
/// - Matches discrete values: -1, -0.5, 0, 0.5, 1 (see CLAUDE.md)
pub const SENTIMENT_NEGATIVE_THRESHOLD: f32 = -0.3;
pub const SENTIMENT_POSITIVE_THRESHOLD: f32 = 0.3;

// Compile-time guards: the negative threshold must sit strictly between
// -0.5 and 0 (so -0.5 counts as a warning but 0 does not), and the positive
// threshold strictly between 0 and 0.5 (so 0.5 counts as a pattern but 0
// does not). Discrete sentiment values are -1, -0.5, 0, 0.5, 1.
const _: () = assert!(SENTIMENT_NEGATIVE_THRESHOLD > -0.5 && SENTIMENT_NEGATIVE_THRESHOLD < 0.0);
const _: () = assert!(SENTIMENT_POSITIVE_THRESHOLD > 0.0 && SENTIMENT_POSITIVE_THRESHOLD < 0.5);

/// Maximum number of notes to parse from a single file.
/// Prevents memory exhaustion from malicious or corrupted note files.
/// Env override `CQS_NOTES_MAX_ENTRIES`.
const MAX_NOTES_DEFAULT: usize = 10_000;

/// Maximum byte length of a note's `text` accepted by the CLI mutation
/// surface (`cqs notes add`, `--new-text`). Hand-edited files may exceed
/// this; the parser tolerates it, but the size cap below still bounds the
/// whole file.
pub const MAX_NOTE_TEXT_BYTES: usize = 2000;

/// Maximum number of mentions a single note may carry through the CLI
/// (`--mentions`, `--new-mentions`). Mentions are file paths or short
/// concept names; 50 is far beyond any legitimate note while keeping the
/// worst-case per-note payload (50 × 256 B) in the same order as the text
/// cap.
pub const MAX_NOTE_MENTIONS: usize = 50;

/// Maximum byte length of a single mention through the CLI. 256 bytes
/// covers deep repository paths with margin.
pub const MAX_NOTE_MENTION_BYTES: usize = 256;

/// Maximum size of `notes.toml` (in bytes) before reads/writes refuse the
/// file. `parse_notes` checks the on-disk size before reading;
/// `rewrite_notes_file` checks both the existing file before parsing AND the
/// serialized output before writing, so a mutation can never produce an
/// over-cap file — when the output would exceed the cap, the rewrite errors
/// and the existing file is left unchanged. Env override
/// `CQS_NOTES_MAX_FILE_SIZE`.
const MAX_NOTES_FILE_SIZE_DEFAULT: u64 = 10 * 1024 * 1024;

/// Resolve `CQS_NOTES_MAX_FILE_SIZE` (default 10 MiB). Parse/warn/default via
/// the shared `crate::limits::parse_env_u64` (warns on a malformed value).
fn max_notes_file_size() -> u64 {
    crate::limits::parse_env_u64("CQS_NOTES_MAX_FILE_SIZE", MAX_NOTES_FILE_SIZE_DEFAULT)
}

/// Resolve `CQS_NOTES_MAX_ENTRIES` (default 10_000). Parse/warn/default via
/// the shared `crate::limits::parse_env_usize` (warns on a malformed value).
fn max_notes() -> usize {
    crate::limits::parse_env_usize("CQS_NOTES_MAX_ENTRIES", MAX_NOTES_DEFAULT)
}

/// Errors that can occur when parsing notes
#[derive(Error, Debug)]
pub enum NoteError {
    /// File read/write error
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    /// Invalid TOML syntax or structure
    #[error("TOML parse error: {0}")]
    Toml(#[from] toml::de::Error),
    /// TOML serialization error
    #[error("TOML serialization error: {0}")]
    TomlSer(#[from] toml::ser::Error),
    /// Note not found
    #[error("Note not found: {0}")]
    NotFound(String),
    /// A note with the same (trimmed) text already exists
    #[error("Duplicate note: {0}")]
    Duplicate(String),
}

/// Raw note entry from TOML (round-trippable via serde)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NoteEntry {
    /// Sentiment: -1.0 (negative/pain) to +1.0 (positive/gain)
    #[serde(default)]
    pub sentiment: f32,
    /// The note content - natural language
    pub text: String,
    /// Code paths/functions mentioned (for linking)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mentions: Vec<String>,
    /// Free-form kind tag (`todo`, `design-decision`, `deprecation`,
    /// `known-bug`, …). When present, takes precedence over `sentiment`'s
    /// implicit "Warning:"/"Pattern:" mapping for the embedding-text prefix;
    /// the structural field also enables `cqs notes list --kind <kind>`
    /// filtering.
    ///
    /// Free-string by design: a closed enum would force a coordinated
    /// edit every time someone wants a new tag. The cqs convention is
    /// kebab-case lowercase; `cqs notes add --kind` does not validate
    /// the value beyond rejecting empty strings.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
}

/// TOML file structure (round-trippable via serde)
#[derive(Debug, Deserialize, Serialize)]
pub struct NoteFile {
    #[serde(default)]
    pub note: Vec<NoteEntry>,
}

/// A parsed note entry
#[derive(Debug, Clone, Serialize)]
pub struct Note {
    /// Unique identifier: "note:{index}"
    pub id: String,
    /// The note content
    pub text: String,
    /// Sentiment: -1.0 to +1.0
    pub sentiment: f32,
    /// Code paths/functions mentioned
    pub mentions: Vec<String>,
    /// Optional structured kind tag (see [`NoteEntry::kind`]). Drives the
    /// embedding-text prefix when present; serialized to JSON only when set so
    /// kind-less notes keep their wire shape.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
}

impl Note {
    /// Generate embedding text for this note.
    ///
    /// Prefix priority:
    /// 1. `kind` is set: `"<Kind>: "` — capitalized, kind takes
    ///    precedence over the sentiment-based fallback so structured
    ///    notes get a structured retrieval signal.
    /// 2. `kind` is None, sentiment < -0.3: `"Warning: "`.
    /// 3. `kind` is None, sentiment > +0.3: `"Pattern: "`.
    /// 4. `kind` is None, sentiment in [-0.3, +0.3]: no prefix.
    ///
    /// Capitalisation: kebab-case kinds (`design-decision`) become
    /// title-case prefixes (`Design-Decision: `) so the embedder sees
    /// readable English. Bare lowercase kinds (`todo`) become
    /// `Todo: `.
    pub fn embedding_text(&self) -> String {
        if let Some(kind) = self.kind.as_deref() {
            return format!("{}: {}", capitalize_kind_for_prefix(kind), self.text);
        }
        let prefix = if self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD {
            "Warning: "
        } else if self.sentiment > SENTIMENT_POSITIVE_THRESHOLD {
            "Pattern: "
        } else {
            ""
        };
        format!("{}{}", prefix, self.text)
    }

    /// Returns the sentiment score of this analysis result.
    /// # Returns
    /// A floating-point value representing the sentiment score, typically in the range [-1.0, 1.0] where negative values indicate negative sentiment, zero indicates neutral sentiment, and positive values indicate positive sentiment.
    pub fn sentiment(&self) -> f32 {
        self.sentiment
    }

    /// Check if this is a warning (negative sentiment)
    pub fn is_warning(&self) -> bool {
        self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD
    }

    /// Check if this is a pattern (positive sentiment)
    pub fn is_pattern(&self) -> bool {
        self.sentiment > SENTIMENT_POSITIVE_THRESHOLD
    }

    /// Get the human-readable sentiment label for this note.
    /// Returns "WARNING" for negative, "PATTERN" for positive, "NOTE" for neutral.
    /// Used by read commands for note injection headers.
    pub fn sentiment_label(&self) -> &'static str {
        if self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD {
            "WARNING"
        } else if self.sentiment > SENTIMENT_POSITIVE_THRESHOLD {
            "PATTERN"
        } else {
            "NOTE"
        }
    }
}

/// File header preserved across rewrites
pub const NOTES_HEADER: &str = "\
# Notes - unified memory for AI collaborators
# Surprises (prediction errors) worth remembering
# sentiment: DISCRETE values only: -1, -0.5, 0, 0.5, 1
#   -1 = serious pain, -0.5 = notable pain, 0 = neutral, 0.5 = notable gain, 1 = major win
";

/// Parse notes from a notes.toml file
pub fn parse_notes(path: &Path) -> Result<Vec<Note>, NoteError> {
    let _span = tracing::debug_span!("parse_notes", path = %path.display()).entered();
    // Lock a separate .lock file (shared) to coordinate with writers.
    // Using a separate lock file avoids the inode-vs-rename race: if we locked
    // the data file itself, a concurrent writer's atomic rename would orphan
    // our lock onto the old inode, letting a third process read stale data.
    //
    // NOTE: File locking is advisory only on WSL over 9P (DrvFs/NTFS mounts).
    // This prevents concurrent cqs processes from corrupting notes,
    // but cannot protect against external Windows process modifications.
    let lock_path = path.with_extension("toml.lock");
    let lock_file = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&lock_path)
        .map_err(|e| {
            NoteError::Io(std::io::Error::new(
                e.kind(),
                format!("{}: {}", lock_path.display(), e),
            ))
        })?;
    lock_file.lock_shared().map_err(|e| {
        NoteError::Io(std::io::Error::new(
            std::io::ErrorKind::WouldBlock,
            format!("Could not lock {} for reading: {}", lock_path.display(), e),
        ))
    })?;

    // Now open and read the data file (protected by the lock file)
    use std::io::Read;
    let mut data_file = std::fs::File::open(path).map_err(|e| {
        NoteError::Io(std::io::Error::new(
            e.kind(),
            format!("{}: {}", path.display(), e),
        ))
    })?;

    // Size guard: notes.toml should be well under 10MB. Env-overridable via
    // CQS_NOTES_MAX_FILE_SIZE, single source of truth at module scope.
    let max_size = max_notes_file_size();
    if let Ok(meta) = data_file.metadata() {
        if meta.len() > max_size {
            return Err(NoteError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!(
                    "{}: file too large ({}MB, limit {}MB)",
                    path.display(),
                    meta.len() / (1024 * 1024),
                    max_size / (1024 * 1024)
                ),
            )));
        }
    }
    let mut content = String::new();
    data_file.read_to_string(&mut content).map_err(|e| {
        NoteError::Io(std::io::Error::new(
            e.kind(),
            format!("{}: {}", path.display(), e),
        ))
    })?;
    // lock_file dropped here, releasing shared lock
    parse_notes_str(&content)
}

/// Rewrite notes.toml by applying a mutation to the parsed entries.
/// Reads the file, parses into `NoteEntry` structs, applies `mutate`,
/// serializes back with the standard header, and writes atomically.
/// Holds an exclusive file lock for the entire read-modify-write cycle.
pub fn rewrite_notes_file(
    notes_path: &Path,
    mutate: impl FnOnce(&mut Vec<NoteEntry>) -> Result<(), NoteError>,
) -> Result<Vec<NoteEntry>, NoteError> {
    let _span = tracing::debug_span!("rewrite_notes_file", path = %notes_path.display()).entered();
    // Lock a separate .lock file (exclusive) to coordinate with readers/writers.
    // See parse_notes() for why we use a separate lock file instead of the data file.
    //
    // NOTE: File locking is advisory only on WSL over 9P (DrvFs/NTFS mounts).
    // This prevents concurrent cqs processes from corrupting notes,
    // but cannot protect against external Windows process modifications.
    let lock_path = notes_path.with_extension("toml.lock");
    let _lock_file = {
        let f = std::fs::OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .open(&lock_path)
            .map_err(|e| {
                NoteError::Io(std::io::Error::new(
                    e.kind(),
                    format!("{}: {}", lock_path.display(), e),
                ))
            })?;
        f.lock().map_err(|e| {
            NoteError::Io(std::io::Error::new(
                std::io::ErrorKind::WouldBlock,
                format!("Could not lock {} for writing: {}", lock_path.display(), e),
            ))
        })?;
        f // held until end of function
    };

    // Now open and read the data file (protected by the lock file)
    use std::io::Read;
    let mut data_file = std::fs::OpenOptions::new()
        .read(true)
        .open(notes_path)
        .map_err(|e| {
            NoteError::Io(std::io::Error::new(
                e.kind(),
                format!("{}: {}", notes_path.display(), e),
            ))
        })?;

    // Size guard (same limit as read path). Shared resolver.
    let max_size = max_notes_file_size();
    if let Ok(meta) = data_file.metadata() {
        if meta.len() > max_size {
            return Err(NoteError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!(
                    "{}: file too large ({}MB, limit {}MB)",
                    notes_path.display(),
                    meta.len() / (1024 * 1024),
                    max_size / (1024 * 1024)
                ),
            )));
        }
    }
    let mut content = String::new();
    data_file.read_to_string(&mut content).map_err(|e| {
        NoteError::Io(std::io::Error::new(
            e.kind(),
            format!("{}: {}", notes_path.display(), e),
        ))
    })?;
    let mut file: NoteFile = toml::from_str(&content)?;

    mutate(&mut file.note)?;

    // Atomic write: temp file + rename (unpredictable suffix to prevent symlink attacks)
    let suffix = crate::temp_suffix();
    let tmp_path = notes_path.with_extension(format!("toml.{:016x}.tmp", suffix));

    let serialized = match toml::to_string_pretty(&file) {
        Ok(s) => s,
        Err(e) => {
            let _ = std::fs::remove_file(&tmp_path);
            return Err(e.into());
        }
    };

    // Wrap the write + permission fixup in a closure so any intermediate
    // failure (disk full, EIO, EROFS) cleans up the tmp file before
    // propagating. A bare `?` on `std::fs::write` would leave
    // `notes.toml.<hex>.tmp` files behind on every failed attempt — names
    // include 16 hex chars of randomness so failures pile up rather than
    // collide.
    //
    // Preserve the existing on-disk line ending convention so cqs doesn't
    // fight with `core.autocrlf=true` on Windows. Without this, every write
    // replaces CRLF with bare LF; git's smudge filter converts back to CRLF on
    // the next checkout; cqs reads the file, sees byte-different content, and
    // reindexes even though the semantic content is identical. Users are
    // encouraged to commit `docs/notes.toml`, so the CRLF interaction would
    // hit every Windows project.
    //
    // Sniff once from `content` (already in memory). If the existing file uses
    // CRLF, translate every bare LF in the formatted output back to CRLF so
    // the round trip is byte-stable.
    let line_ending = if content.contains("\r\n") {
        "\r\n"
    } else {
        "\n"
    };
    let raw_output = format!("{}\n{}", NOTES_HEADER, serialized);
    let output = if line_ending == "\r\n" {
        // Bytewise translate \n → \r\n. The toml serializer emits bare
        // LFs only (no platform variation), so we never double-up CRLF.
        raw_output.replace('\n', "\r\n")
    } else {
        raw_output
    };

    // Size guard on the OUTPUT, not just the input: the pre-parse metadata
    // check above only protects against an already-oversized file. Without
    // this check, a single mutation (e.g. an add with a huge payload) could
    // write past the cap, after which every subsequent parse and rewrite
    // refuses the file — bricking notes until a manual edit. Checked before
    // the tmp write so a rejected mutation leaves the existing file
    // untouched.
    if output.len() as u64 > max_size {
        return Err(NoteError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
                "{}: rewrite would produce a {} byte file, over the {} byte limit \
                 (CQS_NOTES_MAX_FILE_SIZE); existing file left unchanged",
                notes_path.display(),
                output.len(),
                max_size
            ),
        )));
    }

    let write_result: Result<(), NoteError> = (|| {
        std::fs::write(&tmp_path, &output).map_err(|e| {
            NoteError::Io(std::io::Error::new(
                e.kind(),
                format!("{}: {}", tmp_path.display(), e),
            ))
        })?;

        // Restrict permissions BEFORE rename so the file is never world-readable.
        // set_permissions failure is logged at debug! and does not propagate —
        // it's a best-effort hardening, not a correctness requirement, so we
        // don't want to leak the tmp on a permission-set error.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Err(e) =
                std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))
            {
                tracing::debug!(path = %tmp_path.display(), error = %e, "Failed to set file permissions");
            }
        }
        Ok(())
    })();
    if let Err(e) = write_result {
        let _ = std::fs::remove_file(&tmp_path);
        return Err(e);
    }

    // atomic_replace: fsync tmp, rename with EXDEV fallback, fsync parent dir.
    // fsyncing the tmp and parent dir ensures a power cut between write and
    // rename can't lose notes that appeared committed to the user.
    crate::fs::atomic_replace(&tmp_path, notes_path).map_err(|e| {
        let _ = std::fs::remove_file(&tmp_path);
        NoteError::Io(std::io::Error::new(
            e.kind(),
            format!(
                "Failed to persist {} -> {}: {}",
                tmp_path.display(),
                notes_path.display(),
                e
            ),
        ))
    })?;

    Ok(file.note)
}

/// Parse notes from a string (for testing)
/// Note IDs are generated from a hash of the text content (first 16 hex chars = 64 bits).
/// This ensures IDs are stable when notes are reordered in the file.
/// With 16 hex chars, collision probability is ~0.003% at 10k notes (birthday paradox).
/// Limited to `CQS_NOTES_MAX_ENTRIES` (default 10k) to prevent memory exhaustion.
pub fn parse_notes_str(content: &str) -> Result<Vec<Note>, NoteError> {
    let file: NoteFile = toml::from_str(content)?;

    // Surface truncation rather than silently dropping entries — `.take(cap)`
    // eats the surplus, so warn here so users see they need to lift the cap
    // (or split the file).
    let cap = max_notes();
    let total = file.note.len();
    if total > cap {
        tracing::warn!(
            total,
            cap,
            dropped = total - cap,
            "parse_notes_str: note count exceeds CQS_NOTES_MAX_ENTRIES; truncating"
        );
    }

    let notes = file
        .note
        .into_iter()
        .take(cap)
        .map(|entry| {
            // Use content hash for stable IDs (reordering notes won't break references)
            // 16 hex chars = 64 bits, collision probability ~0.003% at 10k notes
            let hash = blake3::hash(entry.text.as_bytes());
            let id = format!("note:{}", &hash.to_hex()[..16]);

            Note {
                id,
                text: entry.text.trim().to_string(),
                sentiment: snap_sentiment(entry.sentiment),
                mentions: entry.mentions,
                kind: entry.kind.and_then(normalize_kind),
            }
        })
        .collect();

    Ok(notes)
}

/// Snap a sentiment value to the nearest of the five discrete documented
/// values (-1, -0.5, 0, 0.5, 1).
///
/// Sentiment is DISCRETE by design (see CLAUDE.md and `docs/notes.toml`), but
/// a hand-edited `notes.toml` can carry any continuous float. Since v29 the
/// `notes.sentiment` column has a CHECK pinning it to exactly those five
/// values, so a continuous input like `-0.8` must be snapped before write or
/// the INSERT fails the constraint. Clamp to `[-1, 1]` first, then
/// `round(s * 2) / 2` lands exactly on the grid. A `NaN` input (which the CLI
/// `--sentiment` parser already rejects via `parse_finite_f32`, but a raw TOML
/// edit could still smuggle in) clamps to `0.0` so the CHECK is satisfied
/// rather than producing a `NaN` that fails it.
pub fn snap_sentiment(raw: f32) -> f32 {
    if raw.is_nan() {
        return 0.0;
    }
    let clamped = raw.clamp(-1.0, 1.0);
    (clamped * 2.0).round() / 2.0
}

/// Normalize a `kind` value: trim whitespace, lowercase, reject empty.
/// Returns `None` when the input was effectively empty so absent /
/// blank kinds round-trip as None.
///
/// We don't enforce a closed taxonomy — kebab-case lowercase is the
/// convention, but `cqs notes add --kind WeirdValue` is allowed.
fn normalize_kind(raw: String) -> Option<String> {
    let trimmed = raw.trim().to_ascii_lowercase();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

/// Capitalize a kebab-case kind for embedding-text prefix display.
/// `"design-decision"` → `"Design-Decision"`. Each `-`-separated
/// segment is title-cased; a bare `"todo"` becomes `"Todo"`. The
/// trailing `": "` separator is added by the caller.
fn capitalize_kind_for_prefix(kind: &str) -> String {
    kind.split('-')
        .map(|seg| {
            let mut chars = seg.chars();
            match chars.next() {
                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join("-")
}

/// Normalize `path` for slash-matching without allocating when possible.
///
/// Returns `Cow::Borrowed(s)` when no substitution is needed (no backslashes —
/// the Unix common case and most Windows paths that already came from
/// `normalize_path`), avoiding the allocation entirely.
fn normalize_slashes_cow(s: &str) -> std::borrow::Cow<'_, str> {
    if s.contains('\\') {
        std::borrow::Cow::Owned(s.replace('\\', "/"))
    } else {
        std::borrow::Cow::Borrowed(s)
    }
}

/// Check if a mention matches a path by component suffix matching.
/// "gather.rs" matches "src/gather.rs" but not "src/gatherer.rs"
/// "src/store" matches "src/store/chunks.rs" but not "my_src/store.rs"
///
/// Uses `Cow<str>` to avoid allocation: in search scoring this runs
/// O(notes × path_mentions × candidates) times per query, so the
/// already-slash-clean path (Unix, and most Windows paths) costs zero
/// allocations while the backslash path keeps identical semantics.
pub fn path_matches_mention(path: &str, mention: &str) -> bool {
    // Normalize backslashes to forward slashes for cross-platform matching
    let path = normalize_slashes_cow(path);
    let mention = normalize_slashes_cow(mention);
    let path: &str = path.as_ref();
    let mention: &str = mention.as_ref();

    // On case-insensitive filesystems (Windows NTFS, macOS HFS+/APFS default),
    // notes authored on Linux would otherwise silently fail to apply —
    // `Cargo.toml` mention vs `cargo.toml` indexed path. Lowercase both sides
    // on those platforms so the suffix/prefix strip is case-insensitive while
    // staying byte-exact on Linux ext4.
    #[cfg(any(target_os = "windows", target_os = "macos"))]
    let (path_buf, mention_buf) = (path.to_ascii_lowercase(), mention.to_ascii_lowercase());
    #[cfg(any(target_os = "windows", target_os = "macos"))]
    let (path, mention) = (path_buf.as_str(), mention_buf.as_str());

    // Check if mention matches as a path suffix (component-aligned)
    if let Some(stripped) = path.strip_suffix(mention) {
        // Must be at component boundary: empty prefix or ends with /
        stripped.is_empty() || stripped.ends_with('/')
    } else if let Some(stripped) = path.strip_prefix(mention) {
        // Check prefix match at component boundary
        stripped.is_empty() || stripped.starts_with('/')
    } else {
        false
    }
}

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

    #[test]
    fn test_parse_notes() {
        let content = r#"
[[note]]
sentiment = -0.8
text = "tree-sitter version mismatch causes mysterious failures"
mentions = ["tree-sitter", "Cargo.toml"]

[[note]]
sentiment = 0.9
text = "OnceCell lazy init pattern works cleanly"
mentions = ["embedder.rs"]

[[note]]
text = "neutral observation without explicit sentiment"
"#;

        let notes = parse_notes_str(content).unwrap();
        assert_eq!(notes.len(), 3);

        // Sentiment is DISCRETE (v29): `parse_notes_str` snaps continuous TOML
        // inputs to the nearest of {-1, -0.5, 0, 0.5, 1} so the value satisfies
        // the `notes.sentiment` CHECK. -0.8 → -1.0, 0.9 → 1.0.
        assert_eq!(notes[0].sentiment, -1.0);
        assert!(notes[0].is_warning());
        assert!(notes[0].embedding_text().starts_with("Warning: "));

        assert_eq!(notes[1].sentiment, 1.0);
        assert!(notes[1].is_pattern());
        assert!(notes[1].embedding_text().starts_with("Pattern: "));

        assert_eq!(notes[2].sentiment, 0.0); // default
        assert!(!notes[2].is_warning());
        assert!(!notes[2].is_pattern());
    }

    /// `snap_sentiment` lands every input on exactly one of the five discrete
    /// values, clamping out-of-range and mapping NaN to 0.
    #[test]
    fn test_snap_sentiment_discrete_grid() {
        assert_eq!(snap_sentiment(-0.8), -1.0);
        assert_eq!(snap_sentiment(-0.7), -0.5);
        assert_eq!(snap_sentiment(-0.3), -0.5);
        assert_eq!(snap_sentiment(-0.2), 0.0);
        assert_eq!(snap_sentiment(0.2), 0.0);
        assert_eq!(snap_sentiment(0.3), 0.5);
        assert_eq!(snap_sentiment(0.9), 1.0);
        assert_eq!(snap_sentiment(5.0), 1.0); // out of range clamps then snaps
        assert_eq!(snap_sentiment(-5.0), -1.0);
        assert_eq!(snap_sentiment(f32::NAN), 0.0);
        // Already-legal values are fixpoints.
        for v in [-1.0, -0.5, 0.0, 0.5, 1.0] {
            assert_eq!(snap_sentiment(v), v, "{v} must be a fixpoint");
        }
    }

    #[test]
    fn test_sentiment_clamping() {
        let content = r#"
[[note]]
sentiment = -5.0
text = "way too negative"

[[note]]
sentiment = 99.0
text = "way too positive"
"#;

        let notes = parse_notes_str(content).unwrap();
        assert_eq!(notes[0].sentiment, -1.0);
        assert_eq!(notes[1].sentiment, 1.0);
    }

    #[test]
    fn test_empty_file() {
        let content = "# Just a comment\n";
        let notes = parse_notes_str(content).unwrap();
        assert!(notes.is_empty());
    }

    #[test]
    fn test_stable_ids_across_reordering() {
        // Original order
        let content1 = r#"
[[note]]
text = "first note"

[[note]]
text = "second note"
"#;

        // Reversed order
        let content2 = r#"
[[note]]
text = "second note"

[[note]]
text = "first note"
"#;

        let notes1 = parse_notes_str(content1).unwrap();
        let notes2 = parse_notes_str(content2).unwrap();

        // IDs should be stable based on content, not order
        assert_eq!(notes1[0].id, notes2[1].id); // "first note" has same ID
        assert_eq!(notes1[1].id, notes2[0].id); // "second note" has same ID

        // Verify ID format (note:16-hex-chars)
        assert!(notes1[0].id.starts_with("note:"));
        assert_eq!(notes1[0].id.len(), 5 + 16); // "note:" + 16 hex chars
    }

    // ===== rewrite_notes_file tests =====

    #[test]
    fn test_rewrite_update_note() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(
            &path,
            "# header\n\n[[note]]\nsentiment = -0.5\ntext = \"old text\"\nmentions = [\"file.rs\"]\n",
        )
        .unwrap();

        rewrite_notes_file(&path, |entries| {
            let entry = entries.iter_mut().find(|e| e.text == "old text").unwrap();
            entry.text = "new text".to_string();
            entry.sentiment = 0.5;
            Ok(())
        })
        .unwrap();

        let notes = parse_notes(&path).unwrap();
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].text, "new text");
        assert_eq!(notes[0].sentiment, 0.5);
        assert_eq!(notes[0].mentions, vec!["file.rs"]);
    }

    #[test]
    fn test_rewrite_remove_note() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(
            &path,
            "[[note]]\ntext = \"keep\"\n\n[[note]]\ntext = \"remove\"\n",
        )
        .unwrap();

        rewrite_notes_file(&path, |entries| {
            entries.retain(|e| e.text != "remove");
            Ok(())
        })
        .unwrap();

        let notes = parse_notes(&path).unwrap();
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].text, "keep");
    }

    #[test]
    fn test_rewrite_preserves_header() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(&path, "[[note]]\ntext = \"hello\"\n").unwrap();

        rewrite_notes_file(&path, |_entries| Ok(())).unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(
            content.starts_with("# Notes"),
            "Should have standard header"
        );
    }

    /// A notes file written with CRLF line endings on disk (e.g., touched by
    /// git's `core.autocrlf=true` smudge filter on Windows) must round-trip
    /// back to CRLF when cqs rewrites it. Without preservation, every cqs note
    /// edit fights with git's smudge filter and triggers a phantom reindex on
    /// every checkout.
    #[test]
    fn test_rewrite_preserves_crlf_line_endings() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        // Write with explicit CRLFs.
        std::fs::write(
            &path,
            "# Notes\r\n[[note]]\r\ntext = \"hello\"\r\nsentiment = 0.0\r\n",
        )
        .unwrap();

        rewrite_notes_file(&path, |_entries| Ok(())).unwrap();

        // Read raw bytes (not lossy through `read_to_string` on Windows-y
        // crates) and assert the file contains CRLF and no bare LFs.
        let bytes = std::fs::read(&path).unwrap();
        assert!(
            bytes.windows(2).any(|w| w == b"\r\n"),
            "rewrite should preserve CRLF line endings (file: {:?})",
            String::from_utf8_lossy(&bytes)
        );
        // Every \n in the result must be preceded by \r — no bare LFs.
        for (i, &b) in bytes.iter().enumerate() {
            if b == b'\n' && i > 0 {
                assert_eq!(
                    bytes[i - 1],
                    b'\r',
                    "bare LF at byte {i}: rewrite leaked \\n through CRLF file"
                );
            }
        }
    }

    /// LF-only files (the Linux/macOS norm) must stay LF-only on rewrite —
    /// no spurious CRLF promotion that would surprise non-Windows users.
    #[test]
    fn test_rewrite_preserves_lf_line_endings() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(
            &path,
            "# Notes\n[[note]]\ntext = \"hello\"\nsentiment = 0.0\n",
        )
        .unwrap();

        rewrite_notes_file(&path, |_entries| Ok(())).unwrap();

        let bytes = std::fs::read(&path).unwrap();
        assert!(
            !bytes.contains(&b'\r'),
            "LF-only file should stay LF-only: {:?}",
            String::from_utf8_lossy(&bytes)
        );
    }

    /// The write side enforces the size cap on the serialized output: a
    /// mutation that would push notes.toml over the limit is rejected with
    /// `InvalidData`, the existing file is left byte-for-byte unchanged, and
    /// a follow-up normal mutation still succeeds (the file is never
    /// bricked by a rejected oversized write).
    #[test]
    fn test_rewrite_rejects_oversized_output_and_leaves_file_usable() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(&path, "[[note]]\ntext = \"small\"\n").unwrap();
        let before = std::fs::read(&path).unwrap();

        // 11 MB of text serializes past the 10 MiB default cap.
        let big_text = "x".repeat(11 * 1024 * 1024);
        let result = rewrite_notes_file(&path, |entries| {
            entries.push(NoteEntry {
                sentiment: 0.0,
                text: big_text.clone(),
                mentions: vec![],
                kind: None,
            });
            Ok(())
        });
        match result {
            Err(NoteError::Io(e)) => {
                assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
                let msg = e.to_string();
                assert!(
                    msg.contains("limit") && msg.contains("notes.toml"),
                    "error should name the file and the limit, got: {msg}"
                );
            }
            other => panic!("expected Io(InvalidData), got: {other:?}"),
        }

        assert_eq!(
            std::fs::read(&path).unwrap(),
            before,
            "rejected oversized rewrite must leave the existing file unchanged"
        );

        // The file is still usable: a normal-sized mutation succeeds.
        rewrite_notes_file(&path, |entries| {
            entries.push(NoteEntry {
                sentiment: 0.0,
                text: "second".into(),
                mentions: vec![],
                kind: None,
            });
            Ok(())
        })
        .expect("notes file must remain usable after a rejected oversized rewrite");
        let notes = parse_notes(&path).unwrap();
        assert_eq!(notes.len(), 2);
    }

    #[test]
    fn test_rewrite_not_found_error() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");
        std::fs::write(&path, "[[note]]\ntext = \"exists\"\n").unwrap();

        let result = rewrite_notes_file(&path, |entries| {
            entries
                .iter()
                .find(|e| e.text == "nonexistent")
                .ok_or_else(|| NoteError::NotFound("not found".into()))?;
            Ok(())
        });

        assert!(result.is_err());
    }

    // ===== size guard + MAX_NOTES truncation =====
    //
    // `parse_notes` has two guards against memory exhaustion:
    //   1. A 10MB file-size cap (`MAX_NOTES_FILE_SIZE_DEFAULT`) that errors
    //      with `ErrorKind::InvalidData` before reading content.
    //   2. A `MAX_NOTES_DEFAULT = 10_000` cap in `parse_notes_str` that
    //      silently truncates via `.take(cap)`.

    /// Over-sized notes file is rejected with a clear `InvalidData` error
    /// and the `"file too large"` phrase, before any TOML parsing.
    #[test]
    fn test_parse_notes_rejects_oversized_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.toml");

        // Build a > 10 MB file. Each entry is cheap to write; pad a single
        // entry's `text` with enough bytes to cross the threshold without
        // needing 10k individual entries (faster + avoids TOML parse cost
        // at the filesystem-read stage).
        //
        // Text size = 11 MB of `x` characters.
        let big_text: String = "x".repeat(11 * 1024 * 1024);
        let content = format!("[[note]]\ntext = \"{}\"\n", big_text);
        std::fs::write(&path, content).unwrap();

        let result = parse_notes(&path);
        match result {
            Err(NoteError::Io(e)) => {
                assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
                let msg = e.to_string();
                assert!(
                    msg.contains("file too large"),
                    "error should mention size limit, got: {}",
                    msg
                );
            }
            other => panic!(
                "expected Io(InvalidData)/'file too large', got: {:?}",
                other.map(|v| v.len())
            ),
        }
    }

    /// Files with > `MAX_NOTES` entries are truncated silently; the parse
    /// succeeds and returns exactly `MAX_NOTES` notes. This is by design —
    /// the memory-exhaustion protection sits at the post-parse step so the
    /// TOML itself can be arbitrarily shaped.
    #[test]
    fn test_parse_notes_str_truncates_at_max_notes() {
        // 10_500 notes is above the 10_000 cap but small enough that TOML
        // parsing stays fast. Each entry text is unique so blake3 IDs differ.
        let mut content = String::with_capacity(10_500 * 40);
        for i in 0..10_500 {
            content.push_str(&format!("[[note]]\ntext = \"note {}\"\n", i));
        }

        let notes = parse_notes_str(&content).expect("truncation must not error");
        assert_eq!(
            notes.len(),
            10_000,
            "parse_notes_str must truncate at MAX_NOTES=10_000, got {}",
            notes.len()
        );
        // First note preserved (the `take(MAX_NOTES)` keeps the prefix).
        assert_eq!(notes[0].text, "note 0");
        // Last note kept is the 10_000th (index 9999).
        assert_eq!(notes[9_999].text, "note 9999");
    }

    /// Exactly `MAX_NOTES` entries is the boundary case — no truncation,
    /// all notes preserved.
    #[test]
    fn test_parse_notes_str_exactly_max_notes() {
        let mut content = String::with_capacity(10_000 * 40);
        for i in 0..10_000 {
            content.push_str(&format!("[[note]]\ntext = \"n{}\"\n", i));
        }

        let notes = parse_notes_str(&content).unwrap();
        assert_eq!(notes.len(), 10_000, "at boundary, no truncation applied");
    }

    // ===== Fuzz tests =====

    mod fuzz {
        use super::*;
        use proptest::prelude::*;

        proptest! {
            /// Fuzz: parse_notes_str should never panic on arbitrary input
            #[test]
            fn fuzz_parse_notes_str_no_panic(input in "\\PC{0,500}") {
                // We don't care about the result, just that it doesn't panic
                let _ = parse_notes_str(&input);
            }

            /// Fuzz: parse_notes_str with TOML-like structure
            #[test]
            fn fuzz_parse_notes_toml_like(
                sentiment in -10.0f64..10.0,
                text in "[a-zA-Z0-9 ]{0,100}",
                mention in "[a-z.]{1,20}"
            ) {
                let input = format!(
                    "[[note]]\nsentiment = {}\ntext = \"{}\"\nmentions = [\"{}\"]",
                    sentiment, text, mention
                );
                let _ = parse_notes_str(&input);
            }

            /// Fuzz: deeply nested/repeated structures
            #[test]
            fn fuzz_parse_notes_repeated(count in 0usize..50) {
                let input: String = (0..count)
                    .map(|i| format!("[[note]]\ntext = \"note {}\"\n", i))
                    .collect();
                let result = parse_notes_str(&input);
                if let Ok(notes) = result {
                    prop_assert!(notes.len() <= count);
                }
            }
        }
    }

    // ===== adversarial content in note entries =====
    //
    // `parse_notes_str` trusts the TOML parser to sanitize, then blake3-hashes
    // the raw text for stable IDs. These tests pin behaviour on three input
    // shapes:
    //
    // * a huge `mentions` array (10k+ entries) — memory-bound, should not
    //   panic or get silently truncated. Only the outer `MAX_NOTES` cap
    //   bounds the note count; per-note mentions have no explicit cap.
    // * an empty `text` field — the blake3 hash of "" is a fixed constant,
    //   so two empty-text notes collide. Parsing succeeds; collision is a
    //   known accepted trade-off.
    // * a NUL byte embedded in `text` — the TOML parser accepts NUL in a
    //   double-quoted string (encoded as ` `) and `parse_notes_str`
    //   preserves it verbatim. This pins the current behaviour so a future
    //   sanitation layer is deliberate.

    /// 10k-entry `mentions` array round-trips without panic or truncation.
    /// `MAX_NOTES = 10_000` caps the note count, not the per-note mentions,
    /// so a single note can carry an arbitrary-length `mentions` vector
    /// bounded only by the file-size guard.
    #[test]
    fn test_parse_notes_str_huge_mentions_array() {
        let mut mentions = String::with_capacity(10_000 * 16);
        mentions.push('[');
        for i in 0..10_000 {
            if i > 0 {
                mentions.push(',');
            }
            mentions.push_str(&format!("\"file{i}.rs\""));
        }
        mentions.push(']');

        let content = format!(
            "[[note]]\ntext = \"huge mentions\"\nmentions = {}\n",
            mentions
        );
        let notes = parse_notes_str(&content)
            .expect("parse_notes_str must accept a 10k-entry mentions array");
        assert_eq!(notes.len(), 1);
        assert_eq!(
            notes[0].mentions.len(),
            10_000,
            "all mentions preserved — no silent truncation"
        );
        assert_eq!(notes[0].mentions[0], "file0.rs");
        assert_eq!(notes[0].mentions[9_999], "file9999.rs");
    }

    /// Empty `text` field parses cleanly. The hash-based ID is a fixed
    /// constant for the empty string, so multiple empty-text notes collide
    /// on a single ID — the small collision rate is accepted for the
    /// higher-priority "stable across reordering" property. This test pins
    /// that empty text is NOT rejected.
    #[test]
    fn test_parse_notes_str_empty_text() {
        let content = "[[note]]\ntext = \"\"\n";
        let notes = parse_notes_str(content).expect("empty text must parse (not error)");
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].text, "", "empty text preserved as empty string");
        assert!(
            notes[0].id.starts_with("note:"),
            "ID should still be generated from blake3 hash of empty string"
        );
    }

    /// A NUL byte embedded in `text` (via TOML ` `) is preserved
    /// verbatim. If a future sanitation layer rejects or strips NUL bytes,
    /// update this test to reflect the new contract.
    #[test]
    fn test_parse_notes_str_nul_byte_in_text_preserved_verbatim() {
        let content = "[[note]]\ntext = \"has \\u0000 nul\"\n";
        let notes =
            parse_notes_str(content).expect("NUL in text must not error at parse time today");
        assert_eq!(notes.len(), 1);
        assert!(
            notes[0].text.contains('\0'),
            "AUDIT-FOLLOWUP (TC-ADV-1.29-5): NUL byte preserved verbatim today — \
             got {:?}",
            notes[0].text
        );
        // The full text should retain its surrounding characters.
        assert!(notes[0].text.starts_with("has "));
        assert!(notes[0].text.ends_with(" nul"));
    }

    /// NUL-only text (`" "`) — boundary case for the preserve-verbatim
    /// contract. After `.trim()` on `text` the NUL stays (trim only strips
    /// ASCII whitespace). The ID derives from the unstripped raw text so
    /// two "NUL-only" notes collide — there is no guard here.
    #[test]
    fn test_parse_notes_str_nul_only_text() {
        let content = "[[note]]\ntext = \"\\u0000\"\n";
        let notes = parse_notes_str(content).expect("NUL-only text should not error");
        assert_eq!(notes.len(), 1);
        assert_eq!(
            notes[0].text, "\0",
            "NUL-only text survives trim — it is not ASCII whitespace"
        );
    }

    /// kind round-trips through TOML parse. Lower-cased, trimmed, with
    /// empty-string normalized to `None`.
    #[test]
    fn test_parse_notes_str_with_kind() {
        let toml = r#"
[[note]]
text = "Add caching layer for embeddings"
sentiment = 0
kind = "todo"

[[note]]
text = "BGE-large beats E5 on v3.v2"
sentiment = 1
kind = "Design-Decision"

[[note]]
text = "neutral observation"
sentiment = 0

[[note]]
text = "blank kind reads as none"
sentiment = 0
kind = ""
"#;
        let notes = parse_notes_str(toml).unwrap();
        assert_eq!(notes.len(), 4);
        assert_eq!(notes[0].kind.as_deref(), Some("todo"));
        // Mixed-case input is normalized to lowercase by `normalize_kind`.
        assert_eq!(notes[1].kind.as_deref(), Some("design-decision"));
        // Sentiment-only note: kind is None.
        assert_eq!(notes[2].kind, None);
        // Explicit empty kind is normalized to None — same as absent.
        assert_eq!(notes[3].kind, None);
    }

    /// `embedding_text()` prefix priority. Kind wins over sentiment when set;
    /// sentiment-based prefixes still apply when kind is None.
    #[test]
    fn test_embedding_text_prefix_priority() {
        // Kind set → kind prefix (capitalized).
        let n = Note {
            id: "x".into(),
            text: "Investigate dim mismatch".into(),
            sentiment: -1.0,
            mentions: vec![],
            kind: Some("known-bug".into()),
        };
        assert_eq!(
            n.embedding_text(),
            "Known-Bug: Investigate dim mismatch",
            "kind prefix wins over negative-sentiment Warning prefix"
        );

        // No kind, negative sentiment → "Warning:".
        let n = Note {
            id: "y".into(),
            text: "Index lock not released".into(),
            sentiment: -1.0,
            mentions: vec![],
            kind: None,
        };
        assert_eq!(n.embedding_text(), "Warning: Index lock not released");

        // No kind, positive sentiment → "Pattern:".
        let n = Note {
            id: "z".into(),
            text: "BGE-large is the production embedder".into(),
            sentiment: 1.0,
            mentions: vec![],
            kind: None,
        };
        assert_eq!(
            n.embedding_text(),
            "Pattern: BGE-large is the production embedder"
        );

        // Kind="todo" + neutral sentiment → "Todo:" (single-segment).
        let n = Note {
            id: "w".into(),
            text: "Review the eval fixture".into(),
            sentiment: 0.0,
            mentions: vec![],
            kind: Some("todo".into()),
        };
        assert_eq!(n.embedding_text(), "Todo: Review the eval fixture");
    }

    /// `capitalize_kind_for_prefix` handles single + multi-segment kebab-case
    /// strings. Pinning the formatting protects against a future refactor that
    /// passes raw lowercase to the embedder (the embedder treats
    /// `Design-Decision` as more meaningful English than `design-decision`).
    #[test]
    fn test_capitalize_kind_for_prefix() {
        assert_eq!(capitalize_kind_for_prefix("todo"), "Todo");
        assert_eq!(
            capitalize_kind_for_prefix("design-decision"),
            "Design-Decision"
        );
        assert_eq!(capitalize_kind_for_prefix("known-bug"), "Known-Bug");
        // Three-segment kebab-case stays consistent.
        assert_eq!(capitalize_kind_for_prefix("a-b-c"), "A-B-C");
        // Single empty segment falls through cleanly.
        assert_eq!(capitalize_kind_for_prefix(""), "");
    }
}
