//! Auto-maintained `index.md` for wiki directories.
//!
//! Every wiki directory can carry an `index.md` whose link list is rewritten
//! between `<!-- HALLOUMINATE:INDEX-START -->` and
//! `<!-- HALLOUMINATE:INDEX-END -->` markers after a write or delete. The
//! prose outside markers stays exactly as the author wrote it — the daemon
//! never touches it. Authors opt out by removing the markers; we skip files
//! without them.
//!
//! The list inside the markers is generated by reading the directory's
//! immediate children (with a recursive existence check on each subdir
//! to prune empty subtrees) plus an H1 sniff per child markdown file.
//! It lists immediate-child markdown files first (excluding `index.md`
//! itself) and immediate-child subdirectories that themselves contain
//! markdown. That gives a consumer LLM a navigable tree without having
//! to ground or list_files; an author LLM can use it as a hand-off
//! summary of what's in the corpus.

use std::ffi::OsStr;
use std::path::{Path, PathBuf};

/// Filename of the auto-maintained directory index.
pub const INDEX_FILENAME: &str = "index.md";
/// Opening fence of the auto-generated link block; prose before it is the
/// author's and left untouched.
pub const INDEX_START_MARKER: &str = "<!-- HALLOUMINATE:INDEX-START -->";
/// Closing fence of the auto-generated link block; prose after it is the
/// author's and left untouched.
pub const INDEX_END_MARKER: &str = "<!-- HALLOUMINATE:INDEX-END -->";

/// Directories from the corpus root down to the parent of `file_relative`,
/// inclusive. Each entry is absolute, joined under `corpus_root`. The root
/// itself is always included so its `index.md` is rebuilt on every write.
pub fn ancestor_dirs(corpus_root: &Path, file_relative: &Path) -> Vec<PathBuf> {
    let mut out = vec![corpus_root.to_path_buf()];
    let Some(parent) = file_relative.parent() else {
        return out;
    };
    let mut acc = corpus_root.to_path_buf();
    for component in parent.components() {
        if let std::path::Component::Normal(seg) = component {
            acc.push(seg);
            out.push(acc.clone());
        }
    }
    out
}

/// True when `path` is `<dir>/index.md` (or just `index.md` at the root) —
/// the file the auto-builder owns. Used to skip rewriting the index that
/// add_markdown / delete_markdown just touched, so the LLM's verbatim
/// write is the final word for the leaf file itself.
pub fn is_index_md(file_relative: &Path) -> bool {
    file_relative
        .file_name()
        .and_then(OsStr::to_str)
        .map(|n| n == INDEX_FILENAME)
        .unwrap_or(false)
}

/// One immediate-child entry rendered into the link list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildEntry {
    /// Markdown-formatted line, e.g. `- [foo](./foo.md) — Foo topic`.
    pub line: String,
    /// Sort key (lowercased name with a `/` suffix on directories so dirs
    /// sort ahead of files of the same prefix).
    pub sort_key: String,
}

/// Read the first H1 from `path` (`# Title`). Returns `None` if the file is
/// unreadable or has no leading H1. Used for the trailing gloss on each
/// link entry, so the list is browsable without opening every child.
pub fn read_h1(path: &Path) -> Option<String> {
    let text = std::fs::read_to_string(path).ok()?;
    let mut lines = text.lines().peekable();

    // Skip a leading YAML frontmatter block, but only when the `---` fence
    // opens on line 1 (per the spec). A mid-document `---` is a thematic
    // break, not frontmatter, so it must not be mistaken for one.
    if lines.peek() == Some(&"---") {
        lines.next();
        // Consume through the closing fence. Unterminated frontmatter means
        // there's no body H1 to find — bail rather than scan into the content.
        loop {
            match lines.next() {
                Some("---") => break,
                Some(_) => continue,
                None => return None,
            }
        }
    }

    for line in lines {
        let trimmed = line.trim_start();
        if trimmed.is_empty() {
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("# ") {
            let title = rest.trim().to_string();
            return if title.is_empty() { None } else { Some(title) };
        }
        // First non-blank line wasn't an H1 — bail. The wiki convention
        // requires the H1 to be the first non-blank line, so anything else
        // means this file opted out.
        return None;
    }
    None
}

/// Enumerate the immediate-child entries to render for `dir`'s `index.md`.
/// Reads one directory; does not recurse. Includes:
///   - `*.md` files except `index.md` itself
///   - subdirectories that (anywhere beneath them) contain at least one
///     `*.md` so dirs that have nothing-but-junk don't pollute the list
pub fn enumerate_children(dir: &Path) -> std::io::Result<Vec<ChildEntry>> {
    let mut out: Vec<ChildEntry> = Vec::new();
    let read = match std::fs::read_dir(dir) {
        Ok(r) => r,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(e) => return Err(e),
    };
    for entry in read {
        let entry = entry?;
        let file_type = entry.file_type()?;
        // Refuse to traverse symlinks — they could redirect into another
        // corpus or outside the wiki root entirely. The daemon's
        // ensure_wiki_root_safe guards the wiki ROOT; this guards every
        // child the auto-index considers.
        if file_type.is_symlink() {
            continue;
        }
        let name_os = entry.file_name();
        let Some(name) = name_os.to_str() else {
            continue;
        };
        if file_type.is_file() {
            if !name.ends_with(".md") || name == INDEX_FILENAME {
                continue;
            }
            let stem = name.strip_suffix(".md").unwrap_or(name);
            let gloss = read_h1(&entry.path()).unwrap_or_default();
            let line = render_file_line(name, stem, &gloss);
            out.push(ChildEntry {
                line,
                sort_key: format!("1:{}", name.to_lowercase()),
            });
        } else if file_type.is_dir() {
            // Skip dotfiles — `.git`, `.cache`, etc. — and any subdir
            // without markdown inside.
            if name.starts_with('.') || !dir_contains_markdown(&entry.path()) {
                continue;
            }
            let gloss = read_h1(&entry.path().join(INDEX_FILENAME)).unwrap_or_default();
            let line = render_dir_line(name, &gloss);
            out.push(ChildEntry {
                line,
                sort_key: format!("0:{}", name.to_lowercase()),
            });
        }
    }
    out.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
    Ok(out)
}

fn render_file_line(filename: &str, stem: &str, gloss: &str) -> String {
    if gloss.is_empty() {
        format!("- [{stem}](./{filename})")
    } else {
        format!("- [{stem}](./{filename}) — {gloss}")
    }
}

fn render_dir_line(dirname: &str, gloss: &str) -> String {
    if gloss.is_empty() {
        format!("- [{dirname}/](./{dirname}/{INDEX_FILENAME})")
    } else {
        format!("- [{dirname}/](./{dirname}/{INDEX_FILENAME}) — {gloss}")
    }
}

fn dir_contains_markdown(dir: &Path) -> bool {
    let Ok(read) = std::fs::read_dir(dir) else {
        return false;
    };
    for entry in read.flatten() {
        let Ok(ft) = entry.file_type() else { continue };
        if ft.is_symlink() {
            continue;
        }
        if ft.is_file() {
            let name = entry.file_name();
            if name.to_str().is_some_and(|n| n.ends_with(".md")) {
                return true;
            }
        } else if ft.is_dir() {
            let name = entry.file_name();
            if name.to_string_lossy().starts_with('.') {
                continue;
            }
            if dir_contains_markdown(&entry.path()) {
                return true;
            }
        }
    }
    false
}

/// Render the default scaffold for a newly-created `index.md`. Includes the
/// H1 and the empty marker block — the caller fills the block on the next
/// pass.
pub fn render_scaffold(title: &str) -> String {
    format!("# {title}\n\n{INDEX_START_MARKER}\n{INDEX_END_MARKER}\n",)
}

/// Render the marker-bounded block body for `dir`.
pub fn render_block_body(children: &[ChildEntry]) -> String {
    if children.is_empty() {
        return String::new();
    }
    children
        .iter()
        .map(|c| c.line.as_str())
        .collect::<Vec<_>>()
        .join("\n")
        + "\n"
}

/// Outcome of a single `rewrite_index_md` call. `Untouched` keeps the
/// post-write reindex path honest — it would otherwise re-embed every
/// ancestor index on every mutation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewriteOutcome {
    /// File didn't exist; scaffold + populated body written.
    Created,
    /// Existing file rewritten between markers.
    Updated,
    /// File exists but has no markers — author opted out; we left it.
    NoMarkers,
    /// File exists; rebuilt block matched what was already there.
    Unchanged,
}

/// Best-effort title for a wiki directory derived from its absolute path —
/// the directory basename, or "wiki" at the root. Used only for the
/// scaffold H1; once a file exists we never touch its H1 again.
pub fn dir_title(dir: &Path, is_root: bool) -> String {
    if is_root {
        return "wiki".to_string();
    }
    dir.file_name()
        .and_then(OsStr::to_str)
        .map(|s| s.to_string())
        .unwrap_or_else(|| "wiki".to_string())
}

/// Compose the index.md content for `dir`. Returns the new file body and the
/// outcome relative to `existing` (so callers can skip a redundant write
/// when the rebuilt body matches what was already on disk).
pub fn compose_index_md(
    dir: &Path,
    is_root: bool,
    existing: Option<&str>,
) -> std::io::Result<(String, RewriteOutcome)> {
    let children = enumerate_children(dir)?;
    let body = render_block_body(&children);
    match existing {
        None => {
            let title = dir_title(dir, is_root);
            let scaffold = render_scaffold(&title);
            let new_content = inject_block(&scaffold, &body).unwrap_or(scaffold);
            Ok((new_content, RewriteOutcome::Created))
        }
        Some(existing) => match inject_block(existing, &body) {
            Some(new_content) => {
                if new_content == existing {
                    Ok((new_content, RewriteOutcome::Unchanged))
                } else {
                    Ok((new_content, RewriteOutcome::Updated))
                }
            }
            None => Ok((existing.to_string(), RewriteOutcome::NoMarkers)),
        },
    }
}

/// Replace whatever's between the markers with `body`. Returns `None` when
/// the markers are missing — the author opted out and we shouldn't touch
/// the file.
pub fn inject_block(existing: &str, body: &str) -> Option<String> {
    let start = existing.find(INDEX_START_MARKER)?;
    let end_rel = existing[start..].find(INDEX_END_MARKER)?;
    let end = start + end_rel;
    let body_section = if body.is_empty() {
        format!("{INDEX_START_MARKER}\n")
    } else {
        format!("{INDEX_START_MARKER}\n{body}")
    };
    let mut out = String::with_capacity(existing.len() + body.len());
    out.push_str(&existing[..start]);
    out.push_str(&body_section);
    out.push_str(&existing[end..]);
    Some(out)
}

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

    #[test]
    fn ancestor_dirs_returns_root_only_for_top_level_file() {
        let root = PathBuf::from("/r");
        let dirs = ancestor_dirs(&root, Path::new("foo.md"));
        assert_eq!(dirs, vec![PathBuf::from("/r")]);
    }

    #[test]
    fn ancestor_dirs_walks_each_parent_for_nested_file() {
        let root = PathBuf::from("/r");
        let dirs = ancestor_dirs(&root, Path::new("a/b/c.md"));
        assert_eq!(
            dirs,
            vec![
                PathBuf::from("/r"),
                PathBuf::from("/r/a"),
                PathBuf::from("/r/a/b"),
            ],
        );
    }

    #[test]
    fn is_index_md_matches_index_files_only() {
        assert!(is_index_md(Path::new("index.md")));
        assert!(is_index_md(Path::new("foo/index.md")));
        assert!(!is_index_md(Path::new("foo.md")));
        assert!(!is_index_md(Path::new("foo/bar.md")));
        // A directory literally named "index.md" (no, but still) — the
        // check is filename-only by design.
        assert!(is_index_md(Path::new("a/b/c/index.md")));
    }

    #[test]
    fn read_h1_returns_title_when_first_line_is_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "# Hello world\n\nbody\n").unwrap();
        assert_eq!(read_h1(&path), Some("Hello world".to_string()));
    }

    #[test]
    fn read_h1_skips_leading_blank_lines() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "\n\n# Title\n").unwrap();
        assert_eq!(read_h1(&path), Some("Title".to_string()));
    }

    #[test]
    fn read_h1_returns_none_when_first_non_blank_is_not_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "intro text\n# late\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_returns_none_for_missing_file() {
        assert_eq!(read_h1(Path::new("/does/not/exist")), None);
    }

    #[test]
    fn read_h1_skips_frontmatter_before_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "---\nup:\n  - \"[[bar]]\"\n---\n\n# Foo Title\n").unwrap();
        assert_eq!(read_h1(&path), Some("Foo Title".to_string()));
    }

    #[test]
    fn read_h1_returns_none_when_first_content_after_frontmatter_is_not_h1() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        std::fs::write(&path, "---\nup: bar\n---\n\nintro text\n# late\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_returns_none_for_unterminated_frontmatter() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // No closing fence — must not over-scan into the body and grab the H1.
        std::fs::write(&path, "---\nup: bar\n# Not a real title\n").unwrap();
        assert_eq!(read_h1(&path), None);
    }

    #[test]
    fn read_h1_skips_frontmatter_with_crlf_line_endings() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // `str::lines()` strips the trailing `\r`, so the `== "---"` fence
        // checks match under CRLF. Lock that so a future move off `lines()`
        // can't silently regress Windows-authored frontmatter pages.
        std::fs::write(&path, "---\r\nup: bar\r\n---\r\n\r\n# Foo Title\r\n").unwrap();
        assert_eq!(read_h1(&path), Some("Foo Title".to_string()));
    }

    #[test]
    fn read_h1_treats_mid_body_dashes_as_thematic_break_not_frontmatter() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("a.md");
        // `---` not on line 1 is a thematic break; H1 detection is unchanged.
        std::fs::write(&path, "# Real Title\n\n---\n\nbody\n").unwrap();
        assert_eq!(read_h1(&path), Some("Real Title".to_string()));
    }

    #[test]
    fn enumerate_children_skips_index_md_and_includes_h1_gloss() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("index.md"), "# index\n").unwrap();
        std::fs::write(tmp.path().join("alpha.md"), "# Alpha topic\n").unwrap();
        std::fs::write(tmp.path().join("beta.md"), "no h1 here\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(
            lines,
            vec!["- [alpha](./alpha.md) — Alpha topic", "- [beta](./beta.md)",],
        );
    }

    #[test]
    fn enumerate_children_lists_subdirs_with_markdown_before_files() {
        let tmp = tempfile::tempdir().unwrap();
        let sub = tmp.path().join("nested");
        std::fs::create_dir(&sub).unwrap();
        std::fs::write(sub.join("inner.md"), "# Inner\n").unwrap();
        std::fs::write(sub.join("index.md"), "# Nested index\n").unwrap();
        std::fs::write(tmp.path().join("top.md"), "# Top\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(
            lines,
            vec![
                "- [nested/](./nested/index.md) — Nested index",
                "- [top](./top.md) — Top",
            ],
        );
    }

    #[test]
    fn enumerate_children_omits_dirs_without_any_markdown() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("empty")).unwrap();
        let no_md = tmp.path().join("no_md");
        std::fs::create_dir(&no_md).unwrap();
        std::fs::write(no_md.join("readme.txt"), "x").unwrap();
        std::fs::write(tmp.path().join("a.md"), "# A\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(lines, vec!["- [a](./a.md) — A"]);
    }

    #[test]
    fn enumerate_children_skips_symlinks_into_corpus() {
        // Defense in depth against a malicious-or-buggy corpus that swaps a
        // child for a symlink. The wiki root itself is guarded by
        // `ensure_wiki_root_safe` daemon-side; child symlinks could still
        // redirect to attacker-controlled markdown.
        let tmp = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        std::fs::write(outside.path().join("alien.md"), "# Alien\n").unwrap();
        std::os::unix::fs::symlink(outside.path().join("alien.md"), tmp.path().join("alien.md"))
            .unwrap();
        std::fs::write(tmp.path().join("real.md"), "# Real\n").unwrap();
        let children = enumerate_children(tmp.path()).unwrap();
        let lines: Vec<&str> = children.iter().map(|c| c.line.as_str()).collect();
        assert_eq!(lines, vec!["- [real](./real.md) — Real"]);
    }

    #[test]
    fn compose_index_md_scaffolds_when_existing_is_none() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, None).unwrap();
        assert_eq!(outcome, RewriteOutcome::Created);
        assert!(content.starts_with("# wiki"), "got: {content}");
        assert!(content.contains(INDEX_START_MARKER));
        assert!(content.contains(INDEX_END_MARKER));
        assert!(content.contains("- [a](./a.md) — Alpha"));
    }

    #[test]
    fn compose_index_md_rewrites_only_between_markers_preserving_prose() {
        let tmp = tempfile::tempdir().unwrap();
        let prose = format!(
            "# Custom title\n\nThis is curated prose the author wrote.\n\n\
             {INDEX_START_MARKER}\n- [stale](./stale.md) — Stale\n{INDEX_END_MARKER}\n\n\
             ## Footer\n\nMore prose.\n",
        );
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(&prose)).unwrap();
        assert_eq!(outcome, RewriteOutcome::Updated);
        assert!(content.starts_with("# Custom title"), "prose H1 preserved");
        assert!(content.contains("This is curated prose"));
        assert!(content.contains("## Footer"), "trailing prose preserved");
        assert!(content.contains("- [a](./a.md) — Alpha"));
        assert!(!content.contains("stale"), "old entry removed");
    }

    #[test]
    fn compose_index_md_returns_no_markers_when_author_opted_out() {
        let tmp = tempfile::tempdir().unwrap();
        let prose = "# Author-only\n\nNo markers here.\n";
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(prose)).unwrap();
        assert_eq!(outcome, RewriteOutcome::NoMarkers);
        assert_eq!(content, prose, "file untouched");
    }

    #[test]
    fn compose_index_md_unchanged_when_block_matches_existing() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.md"), "# Alpha\n").unwrap();
        let current =
            format!("# wiki\n\n{INDEX_START_MARKER}\n- [a](./a.md) — Alpha\n{INDEX_END_MARKER}\n",);
        let (content, outcome) = compose_index_md(tmp.path(), true, Some(&current)).unwrap();
        assert_eq!(outcome, RewriteOutcome::Unchanged);
        assert_eq!(content, current);
    }

    #[test]
    fn inject_block_replaces_existing_content_between_markers() {
        let input =
            format!("prefix {INDEX_START_MARKER}\nold\nmore old\n{INDEX_END_MARKER} suffix");
        let out = inject_block(&input, "new\n").unwrap();
        assert_eq!(
            out,
            format!("prefix {INDEX_START_MARKER}\nnew\n{INDEX_END_MARKER} suffix"),
        );
    }

    #[test]
    fn inject_block_handles_empty_body() {
        let input = format!("{INDEX_START_MARKER}\nold\n{INDEX_END_MARKER}");
        let out = inject_block(&input, "").unwrap();
        assert_eq!(out, format!("{INDEX_START_MARKER}\n{INDEX_END_MARKER}"));
    }

    #[test]
    fn inject_block_returns_none_without_markers() {
        assert_eq!(inject_block("no markers anywhere", "body").as_deref(), None);
    }
}
