use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::annotation::{Annotation, Record};

/// A parsed `.qual` file.
#[derive(Debug, Clone)]
pub struct QualFile {
    /// Path to the `.qual` file on disk.
    pub path: PathBuf,
    /// The subject this file describes (path minus `.qual` suffix).
    pub subject: String,
    /// Records in file order (oldest first).
    pub records: Vec<Record>,
}

/// Parse a `.qual` file from disk.
///
/// Skips empty lines and lines starting with `//` (comments).
/// Each non-comment line must be a valid JSON record.
pub fn parse(path: &Path) -> crate::Result<QualFile> {
    let content = fs::read_to_string(path)?;
    let subject = subject_name(path);
    let mut records = Vec::new();

    for (line_no, line) in content.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with("//") {
            continue;
        }
        let record: Record = serde_json::from_str(trimmed).map_err(|e| {
            crate::Error::Validation(format!("{}:{}: {}", path.display(), line_no + 1, e))
        })?;
        records.push(record);
    }

    Ok(QualFile {
        path: path.to_path_buf(),
        subject,
        records,
    })
}

/// Parse records from a string (for testing or in-memory use).
pub fn parse_str(content: &str) -> crate::Result<Vec<Record>> {
    let mut records = Vec::new();
    for (line_no, line) in content.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with("//") {
            continue;
        }
        let record: Record = serde_json::from_str(trimmed)
            .map_err(|e| crate::Error::Validation(format!("line {}: {}", line_no + 1, e)))?;
        records.push(record);
    }
    Ok(records)
}

/// Append a record to a `.qual` file.
///
/// Creates the file if it doesn't exist. Always appends with a trailing newline.
pub fn append(path: &Path, record: &Record) -> crate::Result<()> {
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    let json = serde_json::to_string(record)?;
    writeln!(file, "{json}")?;
    Ok(())
}

/// Write a complete `.qual` file (used by compaction).
pub fn write_all(path: &Path, records: &[Record]) -> crate::Result<()> {
    let mut file = fs::File::create(path)?;
    for record in records {
        let json = serde_json::to_string(record)?;
        writeln!(file, "{json}")?;
    }
    Ok(())
}

/// Resolve which `.qual` file should receive an annotation for the given subject.
///
/// Resolution order:
/// 1. If `explicit_path` is provided, use it unconditionally (`--file` override).
/// 2. If `{subject}.qual` exists, use it (backwards compat with 1:1 layout).
/// 3. Otherwise, use `{parent_dir}/.qual` (recommended directory-level layout).
///
/// Creates parent directories if needed.
pub fn resolve_qual_path(subject: &str, explicit_path: Option<&Path>) -> crate::Result<PathBuf> {
    if let Some(p) = explicit_path {
        if let Some(parent) = p.parent()
            && !parent.as_os_str().is_empty()
            && !parent.exists()
        {
            fs::create_dir_all(parent)?;
        }
        return Ok(p.to_path_buf());
    }

    // 1. Check for existing 1:1 file
    let one_to_one = PathBuf::from(format!("{subject}.qual"));
    if one_to_one.exists() {
        return Ok(one_to_one);
    }

    // 2. Default to directory-level .qual
    let subject_path = Path::new(subject);
    let parent = subject_path.parent().unwrap_or(Path::new("."));
    let dir_qual = if parent.as_os_str().is_empty() {
        PathBuf::from(".qual")
    } else {
        parent.join(".qual")
    };

    // Create parent directories if needed
    if let Some(dir) = dir_qual.parent()
        && !dir.as_os_str().is_empty()
        && !dir.exists()
    {
        fs::create_dir_all(dir)?;
    }

    Ok(dir_qual)
}

/// Find all records for a given subject across all discovered `.qual` files.
pub fn find_records_for<'a>(subject: &str, qual_files: &'a [QualFile]) -> Vec<&'a Record> {
    qual_files
        .iter()
        .flat_map(|qf| qf.records.iter())
        .filter(|r| r.subject() == subject)
        .collect()
}

/// Find all annotations for a given subject across all discovered `.qual` files.
///
/// Filters to annotation records only (excludes epochs, dependencies, etc.).
pub fn find_annotations_for<'a>(subject: &str, qual_files: &'a [QualFile]) -> Vec<&'a Annotation> {
    qual_files
        .iter()
        .flat_map(|qf| qf.records.iter())
        .filter_map(|r| r.as_annotation())
        .filter(|att| att.subject == subject)
        .collect()
}

/// Find which `.qual` file on disk contains records for a given subject.
///
/// Checks for a 1:1 file first (`{subject}.qual`), then the directory-level
/// file (`{parent}/.qual`). Returns `None` if neither exists.
pub fn find_qual_file_for(subject: &str) -> Option<PathBuf> {
    let one_to_one = PathBuf::from(format!("{subject}.qual"));
    if one_to_one.exists() {
        return Some(one_to_one);
    }

    let subject_path = Path::new(subject);
    let parent = subject_path.parent().unwrap_or(Path::new("."));
    let dir_qual = if parent.as_os_str().is_empty() {
        PathBuf::from(".qual")
    } else {
        parent.join(".qual")
    };
    if dir_qual.exists() {
        return Some(dir_qual);
    }

    None
}

/// Discover all `.qual` files under a root directory.
///
/// Walks the directory tree recursively, collecting every file whose name
/// ends with `.qual`. Respects `.gitignore` and `.qualignore` by default.
/// Pass `respect_ignore: false` to bypass all ignore rules.
///
/// Returns them sorted by path for determinism.
pub fn discover(root: &Path, respect_ignore: bool) -> crate::Result<Vec<QualFile>> {
    use ignore::WalkBuilder;

    let mut builder = WalkBuilder::new(root);
    builder.hidden(false); // allow hidden files like .qual

    if respect_ignore {
        builder
            .git_ignore(true)
            .git_global(true)
            .git_exclude(true)
            .add_custom_ignore_filename(".qualignore");
    } else {
        builder
            .git_ignore(false)
            .git_global(false)
            .git_exclude(false)
            .ignore(false);
    }

    // Skip hidden directories (like .git, .vscode, etc.) but allow hidden
    // files (like .qual) — matches the old walk_dir behavior.
    builder.filter_entry(|entry| {
        if entry.file_type().is_some_and(|ft| ft.is_dir()) {
            return !entry.file_name().to_string_lossy().starts_with('.');
        }
        true
    });

    let mut qual_files = Vec::new();
    for entry in builder.build() {
        let entry = entry.map_err(|e| crate::Error::Io(std::io::Error::other(e)))?;
        let path = entry.path();
        if path.is_file()
            && (path.extension().and_then(|e| e.to_str()) == Some("qual")
                || path.file_name().and_then(|f| f.to_str()) == Some(".qual"))
        {
            qual_files.push(parse(path)?);
        }
    }
    qual_files.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(qual_files)
}

/// Derive the subject name from a `.qual` file path.
///
/// - `src/parser.rs.qual` -> `src/parser.rs`
/// - `src/.qual` -> `src/`
pub fn subject_name(qual_path: &Path) -> String {
    let s = qual_path.to_string_lossy();
    if let Some(stripped) = s.strip_suffix(".qual") {
        if stripped.ends_with('/') || stripped.ends_with(std::path::MAIN_SEPARATOR) {
            stripped.to_string()
        } else if qual_path.file_name().map(|f| f.to_string_lossy()) == Some(".qual".into()) {
            // Directory-level: `src/.qual` -> `src/`
            qual_path
                .parent()
                .map(|p| format!("{}/", p.display()))
                .unwrap_or_default()
        } else {
            stripped.to_string()
        }
    } else {
        s.to_string()
    }
}

/// Find the project root by searching upward for VCS markers.
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
    const VCS_MARKERS: &[&str] = &[".git", ".hg", ".jj", ".pijul", "_FOSSIL_", ".svn"];

    let mut current = if start.is_file() {
        start.parent()?.to_path_buf()
    } else {
        start.to_path_buf()
    };

    loop {
        for marker in VCS_MARKERS {
            if current.join(marker).exists() {
                return Some(current);
            }
        }
        match current.parent() {
            Some(parent) if parent != current => current = parent.to_path_buf(),
            _ => return None,
        }
    }
}

/// Detect the VCS in use at a given root.
pub fn detect_vcs(root: &Path) -> Option<&'static str> {
    if root.join(".git").exists() {
        Some("git")
    } else if root.join(".hg").exists() {
        Some("hg")
    } else if root.join(".jj").exists() {
        Some("jj")
    } else if root.join(".pijul").exists() {
        Some("pijul")
    } else if root.join("_FOSSIL_").exists() {
        Some("fossil")
    } else if root.join(".svn").exists() {
        Some("svn")
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::annotation::{self, AnnotationBody, Kind};
    use chrono::Utc;
    use std::fs;

    fn make_annotation(subject: &str, kind: Kind, summary: &str) -> Annotation {
        annotation::finalize(Annotation {
            metabox: "1".into(),
            record_type: "annotation".into(),
            subject: subject.into(),
            issuer: "mailto:test@test.com".into(),
            issuer_type: None,
            created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z")
                .unwrap()
                .with_timezone(&Utc),
            id: String::new(),
            body: AnnotationBody {
                detail: None,
                kind,
                r#ref: None,
                references: None,
                span: None,
                suggested_fix: None,
                summary: summary.into(),
                supersedes: None,
                tags: vec![],
            },
        })
    }

    fn make_record(subject: &str, kind: Kind, summary: &str) -> Record {
        Record::Annotation(Box::new(make_annotation(subject, kind, summary)))
    }

    #[test]
    fn test_subject_name_file() {
        let path = Path::new("src/parser.rs.qual");
        assert_eq!(subject_name(path), "src/parser.rs");
    }

    #[test]
    fn test_subject_name_directory() {
        let path = Path::new("src/.qual");
        assert_eq!(subject_name(path), "src/");
    }

    #[test]
    fn test_parse_and_append() {
        let dir = tempfile::tempdir().unwrap();
        let qual_path = dir.path().join("test.rs.qual");

        let r1 = make_record("test.rs", Kind::Praise, "Good tests");
        let r2 = make_record("test.rs", Kind::Concern, "Missing docs");

        append(&qual_path, &r1).unwrap();
        append(&qual_path, &r2).unwrap();

        let parsed = parse(&qual_path).unwrap();
        assert_eq!(parsed.records.len(), 2);
        assert_eq!(
            parsed.records[0].as_annotation().unwrap().body.summary,
            "Good tests"
        );
        assert_eq!(
            parsed.records[1].as_annotation().unwrap().body.summary,
            "Missing docs"
        );
        assert_eq!(
            parsed.subject,
            qual_path.to_string_lossy().replace(".qual", "")
        );
    }

    #[test]
    fn test_parse_skips_comments_and_blanks() {
        let dir = tempfile::tempdir().unwrap();
        let qual_path = dir.path().join("test.rs.qual");

        let att = make_annotation("test.rs", Kind::Pass, "ok");
        let json = serde_json::to_string(&att).unwrap();

        fs::write(
            &qual_path,
            format!("// This is a comment\n\n{json}\n\n// Another comment\n"),
        )
        .unwrap();

        let parsed = parse(&qual_path).unwrap();
        assert_eq!(parsed.records.len(), 1);
    }

    #[test]
    fn test_discover() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();

        let r1 = make_record("src/a.rs", Kind::Pass, "ok");
        let r2 = make_record("src/b.rs", Kind::Fail, "bad");

        append(&src.join("a.rs.qual"), &r1).unwrap();
        append(&src.join("b.rs.qual"), &r2).unwrap();

        // Also create a non-qual file that should be ignored
        fs::write(src.join("a.rs"), "fn main() {}").unwrap();

        let found = discover(dir.path(), true).unwrap();
        assert_eq!(found.len(), 2);
    }

    #[test]
    fn test_discover_skips_hidden_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let hidden = dir.path().join(".git");
        fs::create_dir_all(&hidden).unwrap();

        let r = make_record("x", Kind::Pass, "ok");
        append(&hidden.join("x.qual"), &r).unwrap();

        let found = discover(dir.path(), true).unwrap();
        assert_eq!(found.len(), 0);
    }

    #[test]
    fn test_discover_respects_qualignore() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        let examples = dir.path().join("examples");
        fs::create_dir_all(&src).unwrap();
        fs::create_dir_all(&examples).unwrap();

        let r1 = make_record("src/a.rs", Kind::Pass, "ok");
        let r2 = make_record("examples/demo.rs", Kind::Pass, "ok");

        append(&src.join("a.rs.qual"), &r1).unwrap();
        append(&examples.join("demo.rs.qual"), &r2).unwrap();

        // Without .qualignore: both found
        let found = discover(dir.path(), true).unwrap();
        assert_eq!(found.len(), 2);

        // Add .qualignore excluding examples/
        fs::write(dir.path().join(".qualignore"), "examples/\n").unwrap();

        let found = discover(dir.path(), true).unwrap();
        assert_eq!(found.len(), 1);
        assert!(found[0].path.to_string_lossy().contains("src"));

        // With --no-ignore: both found again
        let found = discover(dir.path(), false).unwrap();
        assert_eq!(found.len(), 2);
    }

    #[test]
    fn test_write_all() {
        let dir = tempfile::tempdir().unwrap();
        let qual_path = dir.path().join("test.rs.qual");

        let r1 = make_record("test.rs", Kind::Praise, "Good");
        let r2 = make_record("test.rs", Kind::Concern, "Bad");
        let id1 = r1.id().to_string();
        let id2 = r2.id().to_string();

        write_all(&qual_path, &[r1, r2]).unwrap();

        let parsed = parse(&qual_path).unwrap();
        assert_eq!(parsed.records.len(), 2);
        assert_eq!(parsed.records[0].id(), id1);
        assert_eq!(parsed.records[1].id(), id2);
    }

    #[test]
    fn test_find_project_root() {
        let dir = tempfile::tempdir().unwrap();
        let git_dir = dir.path().join(".git");
        fs::create_dir_all(&git_dir).unwrap();
        let sub = dir.path().join("src").join("deep");
        fs::create_dir_all(&sub).unwrap();

        let root = find_project_root(&sub).unwrap();
        assert_eq!(root, dir.path());
    }

    #[test]
    fn test_detect_vcs() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(detect_vcs(dir.path()), None);

        fs::create_dir_all(dir.path().join(".git")).unwrap();
        assert_eq!(detect_vcs(dir.path()), Some("git"));
    }

    #[test]
    fn test_resolve_qual_path_prefers_existing_1to1() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();
        fs::write(src.join("foo.rs.qual"), "").unwrap();

        let subject = dir.path().join("src/foo.rs");
        let path = resolve_qual_path(subject.to_str().unwrap(), None).unwrap();
        assert_eq!(path, PathBuf::from(format!("{}.qual", subject.display())));
    }

    #[test]
    fn test_resolve_qual_path_defaults_to_dir_qual() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();

        // No existing 1:1 file → should resolve to directory .qual
        let subject = dir.path().join("src/foo.rs");
        let path = resolve_qual_path(subject.to_str().unwrap(), None).unwrap();
        assert_eq!(path, src.join(".qual"));
    }

    #[test]
    fn test_resolve_qual_path_root_level_subject() {
        let dir = tempfile::tempdir().unwrap();
        let subject = dir.path().join("README.md");
        let path = resolve_qual_path(subject.to_str().unwrap(), None).unwrap();
        assert_eq!(path, dir.path().join(".qual"));
    }

    #[test]
    fn test_resolve_qual_path_explicit_override() {
        let dir = tempfile::tempdir().unwrap();
        let custom = dir.path().join("custom.qual");
        let subject = dir.path().join("src/foo.rs");
        let path = resolve_qual_path(subject.to_str().unwrap(), Some(&custom)).unwrap();
        assert_eq!(path, custom);
    }

    #[test]
    fn test_resolve_qual_path_creates_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let deep = dir.path().join("src/deep");

        // src/deep/ doesn't exist yet
        let subject = dir.path().join("src/deep/module.rs");
        let path = resolve_qual_path(subject.to_str().unwrap(), None).unwrap();
        assert_eq!(path, deep.join(".qual"));
        assert!(deep.exists(), "parent dir should be created");
    }

    #[test]
    fn test_find_annotations_for_across_files() {
        let att_a1 = make_annotation("src/a.rs", Kind::Praise, "good");
        let att_a2 = make_annotation("src/a.rs", Kind::Concern, "meh");
        let att_b = make_annotation("src/b.rs", Kind::Pass, "ok");

        let qfs = vec![
            QualFile {
                path: PathBuf::from("src/.qual"),
                subject: "src/".into(),
                records: vec![
                    Record::Annotation(Box::new(att_a1.clone())),
                    Record::Annotation(Box::new(att_b.clone())),
                ],
            },
            QualFile {
                path: PathBuf::from("src/a.rs.qual"),
                subject: "src/a.rs".into(),
                records: vec![Record::Annotation(Box::new(att_a2.clone()))],
            },
        ];

        let found = find_annotations_for("src/a.rs", &qfs);
        assert_eq!(found.len(), 2);
        assert!(found.iter().any(|a| a.id == att_a1.id));
        assert!(found.iter().any(|a| a.id == att_a2.id));

        let found_b = find_annotations_for("src/b.rs", &qfs);
        assert_eq!(found_b.len(), 1);
        assert_eq!(found_b[0].id, att_b.id);

        let found_none = find_annotations_for("src/c.rs", &qfs);
        assert!(found_none.is_empty());
    }

    #[test]
    fn test_find_qual_file_for_1to1() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();
        fs::write(src.join("foo.rs.qual"), "").unwrap();

        let subject = format!("{}/foo.rs", src.display());
        let found = find_qual_file_for(&subject);
        assert_eq!(found, Some(PathBuf::from(format!("{subject}.qual"))));
    }

    #[test]
    fn test_find_qual_file_for_dir_qual() {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        fs::create_dir_all(&src).unwrap();
        fs::write(src.join(".qual"), "").unwrap();

        let subject = format!("{}/foo.rs", src.display());
        let found = find_qual_file_for(&subject);
        assert_eq!(found, Some(src.join(".qual")));
    }

    #[test]
    fn test_find_qual_file_for_not_found() {
        let dir = tempfile::tempdir().unwrap();
        let subject = format!("{}/foo.rs", dir.path().join("src").display());
        let found = find_qual_file_for(&subject);
        assert_eq!(found, None);
    }
}
