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

use anyhow::{Context, Result};
use log::warn;
use serde::{Deserialize, Serialize};

use crate::config::{
    GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
    WORKSPACE_COYOTE_DIR_NAME, paths,
};

pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;

#[derive(Debug, Clone)]
pub struct WorkspaceMemory {
    pub workspace_root: PathBuf,
    pub dir: PathBuf,
}

pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
    for dir in start.ancestors() {
        let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
        if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
            return Some(WorkspaceMemory {
                workspace_root: dir.to_path_buf(),
                dir: structured,
            });
        }
    }
    None
}

pub fn find_git_root(start: &Path) -> Option<PathBuf> {
    for dir in start.ancestors() {
        if dir.join(GIT_DIR_NAME).exists() {
            return Some(dir.to_path_buf());
        }
    }

    None
}

pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
    let mem_dir = paths::workspace_memory_dir_for(git_root);
    fs::create_dir_all(&mem_dir)
        .with_context(|| format!("create memory dir {}", mem_dir.display()))?;

    let index_path = mem_dir.join(MEMORY_INDEX_FILE_NAME);
    if !index_path.exists() {
        fs::write(&index_path, "# Workspace Memory Index\n\n")
            .with_context(|| format!("write {}", index_path.display()))?;
    }

    let gitignore_appended = append_gitignore_entry(git_root)?;
    let suffix = if gitignore_appended {
        " (appended .coyote/memory/ to .gitignore)"
    } else {
        ""
    };
    warn!(
        "auto-bootstrapped workspace memory at {}{}",
        mem_dir.display(),
        suffix
    );

    Ok(mem_dir)
}

pub fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
    let gitignore = git_root.join(GITIGNORE_FILE_NAME);
    let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
    let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");

    let existing = fs::read_to_string(&gitignore).unwrap_or_default();
    let already_present = existing.lines().any(|line| {
        let trimmed = line.trim();
        trimmed == entry || trimmed == entry_no_slash
    });

    if already_present {
        return Ok(false);
    }

    let new_content = if existing.is_empty() {
        format!("{entry}\n")
    } else if existing.ends_with('\n') {
        format!("{existing}{entry}\n")
    } else {
        format!("{existing}\n{entry}\n")
    };

    fs::write(&gitignore, new_content).with_context(|| format!("write {}", gitignore.display()))?;

    Ok(true)
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct MemoryFrontmatter {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default, rename = "type")]
    pub kind: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub created: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub superseded_by: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires: Option<String>,
}

#[derive(Debug, Clone)]
pub struct MemoryFile {
    pub path: PathBuf,
    pub frontmatter: MemoryFrontmatter,
    pub body: String,
}

impl MemoryFile {
    pub fn load(path: &Path) -> Result<Self> {
        let raw = fs::read_to_string(path)
            .with_context(|| format!("read memory file {}", path.display()))?;
        let (frontmatter, body) = parse_frontmatter(&raw)
            .with_context(|| format!("parse frontmatter in {}", path.display()))?;

        Ok(Self {
            path: path.to_path_buf(),
            frontmatter,
            body,
        })
    }

    pub fn save(&self) -> Result<()> {
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent)?;
        }

        let frontmatter_yaml = serde_yaml::to_string(&self.frontmatter)?;
        let content = format!("---\n{}---\n\n{}", frontmatter_yaml, self.body);

        fs::write(&self.path, content)?;

        Ok(())
    }

    pub fn char_len(&self) -> usize {
        self.body.chars().count()
    }
}

fn parse_frontmatter(raw: &str) -> Result<(MemoryFrontmatter, String)> {
    let trimmed = raw.trim_start();
    if !trimmed.starts_with("---") {
        return Ok((MemoryFrontmatter::default(), raw.to_string()));
    }

    let after = &trimmed[3..];
    let Some(end) = after.find("\n---") else {
        return Ok((MemoryFrontmatter::default(), raw.to_string()));
    };
    let yaml = &after[..end];
    let body = after[end + 4..].trim_start_matches('\n').to_string();
    let frontmatter: MemoryFrontmatter =
        serde_yaml::from_str(yaml.trim()).context("parse YAML frontmatter")?;

    Ok((frontmatter, body))
}

#[derive(Debug, Clone)]
pub struct MemoryStore {
    pub global_dir: PathBuf,
    pub workspace: Option<WorkspaceMemory>,
}

impl MemoryStore {
    pub fn new(cwd: &Path) -> Self {
        Self {
            global_dir: paths::global_memory_dir(),
            workspace: discover_workspace_memory(cwd),
        }
    }

    pub fn load_global_index(&self) -> Result<Option<String>> {
        let path = self.global_dir.join(MEMORY_INDEX_FILE_NAME);

        if path.exists() {
            Ok(Some(fs::read_to_string(path)?))
        } else {
            Ok(None)
        }
    }

    pub fn load_workspace_index(&self) -> Result<Option<String>> {
        match &self.workspace {
            None => Ok(None),
            Some(ws) => {
                let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
                if index.exists() {
                    Ok(Some(fs::read_to_string(index)?))
                } else {
                    Ok(None)
                }
            }
        }
    }

    pub fn list_files(&self) -> Result<Vec<MemoryFile>> {
        let mut out = Vec::new();

        if self.global_dir.exists() {
            collect_md_files(&self.global_dir, &mut out)?;
        }

        if let Some(ws) = &self.workspace {
            collect_md_files(&ws.dir, &mut out)?;
        }

        Ok(out)
    }
}

pub fn build_memory_section(
    store: &MemoryStore,
    with_tools: bool,
    cap: usize,
) -> Result<Option<String>> {
    let global_index = store.load_global_index()?;
    let workspace_index = store.load_workspace_index()?;

    if global_index.is_none() && workspace_index.is_none() {
        return Ok(None);
    }

    let mut buf = String::from("<memory>\n");
    let mut consumed = 0usize;

    if let Some(s) = &global_index {
        buf.push_str("<global_index>\n");
        buf.push_str(s);
        buf.push_str("\n</global_index>\n");
        consumed += s.chars().count();
    }

    if let Some(s) = &workspace_index {
        buf.push_str("<workspace_index>\n");
        buf.push_str(s);
        buf.push_str("\n</workspace_index>\n");
        consumed += s.chars().count();
    }

    if consumed > cap {
        warn!(
            "memory indexes ({} chars) exceed cap ({} chars); injecting fully - \
             consider raising memory_cap_* in config or shrinking MEMORY.md",
            consumed, cap
        );
    }

    if !with_tools {
        let mut budget = cap.saturating_sub(consumed);
        let mut files = store.list_files()?;
        files.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
        let mut omitted = 0usize;
        for f in files {
            let needed = f.body.chars().count() + 50;
            if needed > budget {
                omitted += 1;
                continue;
            }
            buf.push_str(&format!("<file name=\"{}\">\n", f.frontmatter.name));
            buf.push_str(&f.body);
            buf.push_str("\n</file>\n");
            budget = budget.saturating_sub(needed);
        }

        if omitted > 0 {
            buf.push_str(&format!(
                "<!-- {} memory file(s) omitted; enable function calling for full access -->\n",
                omitted
            ));
        }
    }

    buf.push_str("</memory>");
    Ok(Some(buf))
}

fn collect_md_files(dir: &Path, out: &mut Vec<MemoryFile>) -> Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }

        if path.file_name().and_then(|n| n.to_str()) == Some(MEMORY_INDEX_FILE_NAME) {
            continue;
        }

        match MemoryFile::load(&path) {
            Ok(f) => out.push(f),
            Err(e) => warn!("skip malformed memory file {}: {}", path.display(), e),
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{env, time};
    use time::SystemTime;

    fn temp_root(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let root = env::temp_dir().join(format!("coyote-memory-{label}-{unique}"));
        fs::create_dir_all(&root).unwrap();
        root
    }

    #[test]
    fn loads_global_and_workspace_indexes_from_test_dirs() {
        let root = temp_root("phase1");
        let workspace = root.join("workspace");
        let workspace_memory_dir = workspace
            .join(WORKSPACE_COYOTE_DIR_NAME)
            .join(MEMORY_DIR_NAME);
        fs::create_dir_all(&workspace_memory_dir).unwrap();
        fs::write(
            workspace_memory_dir.join(MEMORY_INDEX_FILE_NAME),
            "workspace-content",
        )
        .unwrap();

        let global = root.join("global");
        fs::create_dir_all(&global).unwrap();
        fs::write(global.join(MEMORY_INDEX_FILE_NAME), "global-content").unwrap();

        let store = MemoryStore {
            global_dir: global,
            workspace: discover_workspace_memory(&workspace),
        };

        assert_eq!(
            store.load_global_index().unwrap().as_deref(),
            Some("global-content")
        );
        assert_eq!(
            store.load_workspace_index().unwrap().as_deref(),
            Some("workspace-content")
        );

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn workspace_discovery_ignores_root_instructions_file() {
        let root = temp_root("no_lite");
        let workspace = root.join("ws");
        fs::create_dir_all(&workspace).unwrap();
        fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();

        assert!(discover_workspace_memory(&workspace).is_none());

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn build_memory_section_returns_none_when_no_memory_exists() {
        let root = temp_root("none");
        let workspace = root.join("ws");
        fs::create_dir_all(&workspace).unwrap();

        let store = MemoryStore {
            global_dir: root.join("global"),
            workspace: discover_workspace_memory(&workspace),
        };

        assert!(build_memory_section(&store, true, 6_000).unwrap().is_none());

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn build_memory_section_injects_only_indexes_with_tools_on() {
        let root = temp_root("indexes_only");
        let workspace = root.join("ws");
        let structured = workspace
            .join(WORKSPACE_COYOTE_DIR_NAME)
            .join(MEMORY_DIR_NAME);
        fs::create_dir_all(&structured).unwrap();
        fs::write(
            structured.join(MEMORY_INDEX_FILE_NAME),
            "workspace-index-content",
        )
        .unwrap();
        fs::write(
            structured.join("foo.md"),
            "---\nname: foo\n---\nfoo body that should not appear\n",
        )
        .unwrap();

        let store = MemoryStore {
            global_dir: root.join("global"),
            workspace: discover_workspace_memory(&workspace),
        };

        let section = build_memory_section(&store, true, 6_000)
            .unwrap()
            .expect("memory section should exist");
        assert!(section.contains("workspace-index-content"));
        assert!(!section.contains("foo body that should not appear"));
        assert!(section.starts_with("<memory>"));
        assert!(section.ends_with("</memory>"));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn build_memory_section_injects_drill_bodies_alphabetically_without_tools() {
        let root = temp_root("drill_bodies");
        let workspace = root.join("ws");
        let structured = workspace
            .join(WORKSPACE_COYOTE_DIR_NAME)
            .join(MEMORY_DIR_NAME);
        fs::create_dir_all(&structured).unwrap();
        fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
        fs::write(
            structured.join("zebra.md"),
            "---\nname: zebra\n---\nzebra body\n",
        )
        .unwrap();
        fs::write(
            structured.join("alpha.md"),
            "---\nname: alpha\n---\nalpha body\n",
        )
        .unwrap();

        let store = MemoryStore {
            global_dir: root.join("global"),
            workspace: discover_workspace_memory(&workspace),
        };

        let section = build_memory_section(&store, false, 6_000)
            .unwrap()
            .expect("memory section should exist");
        let alpha_pos = section.find("alpha body").expect("alpha body missing");
        let zebra_pos = section.find("zebra body").expect("zebra body missing");
        assert!(alpha_pos < zebra_pos, "drill bodies must be alphabetical");

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn build_memory_section_omits_drill_bodies_when_cap_exceeded() {
        let root = temp_root("cap");
        let workspace = root.join("ws");
        let structured = workspace
            .join(WORKSPACE_COYOTE_DIR_NAME)
            .join(MEMORY_DIR_NAME);
        fs::create_dir_all(&structured).unwrap();
        fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
        let big_body = "x".repeat(200);
        fs::write(
            structured.join("big.md"),
            format!("---\nname: big\n---\n{}\n", big_body),
        )
        .unwrap();

        let store = MemoryStore {
            global_dir: root.join("global"),
            workspace: discover_workspace_memory(&workspace),
        };

        let section = build_memory_section(&store, false, 100)
            .unwrap()
            .expect("memory section should exist");
        assert!(!section.contains(&big_body));
        assert!(section.contains("memory file(s) omitted"));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn parse_frontmatter_extracts_yaml() {
        let raw = "---\nname: foo\ndescription: a thing\ntype: user\n---\nBody text\n";

        let (fm, body) = parse_frontmatter(raw).unwrap();

        assert_eq!(fm.name, "foo");
        assert_eq!(fm.description.as_deref(), Some("a thing"));
        assert_eq!(fm.kind.as_deref(), Some("user"));
        assert_eq!(body, "Body text\n");
    }

    #[test]
    fn parse_frontmatter_handles_missing_block() {
        let raw = "# Just markdown, no frontmatter\nbody";

        let (fm, body) = parse_frontmatter(raw).unwrap();

        assert_eq!(fm.name, "");
        assert!(fm.kind.is_none());
        assert_eq!(body, raw);
    }

    #[test]
    fn parse_frontmatter_handles_unterminated_block() {
        let raw = "---\nname: oops\nno closing delimiter\n# rest of doc";

        let (fm, body) = parse_frontmatter(raw).unwrap();

        assert_eq!(fm.name, "");
        assert_eq!(body, raw);
    }

    #[test]
    fn memory_file_save_and_load_roundtrip() {
        let root = temp_root("roundtrip");
        let path = root.join("test.md");
        let file = MemoryFile {
            path: path.clone(),
            frontmatter: MemoryFrontmatter {
                name: "test".into(),
                description: Some("a test".into()),
                kind: Some("user".into()),
                ..Default::default()
            },
            body: "Hello world\nmore text".into(),
        };
        file.save().unwrap();
        let loaded = MemoryFile::load(&path).unwrap();
        assert_eq!(loaded.frontmatter.name, "test");
        assert_eq!(loaded.frontmatter.description.as_deref(), Some("a test"));
        assert_eq!(loaded.frontmatter.kind.as_deref(), Some("user"));
        assert_eq!(loaded.body, "Hello world\nmore text");

        let raw = fs::read_to_string(&path).unwrap();
        assert!(raw.contains("type: user"), "kind must serialize as 'type:'");

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn discover_walks_up_from_nested_dir() {
        let root = temp_root("walk_up");
        let workspace = root.join("ws");
        let mem_dir = workspace
            .join(WORKSPACE_COYOTE_DIR_NAME)
            .join(MEMORY_DIR_NAME);
        fs::create_dir_all(&mem_dir).unwrap();
        fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), "idx").unwrap();
        let nested = workspace.join("src").join("deep").join("path");
        fs::create_dir_all(&nested).unwrap();

        let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
        assert_eq!(found.dir, mem_dir);

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn find_git_root_returns_dir_containing_git_dir() {
        let root = temp_root("git_root");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();

        assert_eq!(find_git_root(&repo), Some(repo.clone()));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn find_git_root_walks_up_from_nested_dir() {
        let root = temp_root("git_root_walk");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
        let nested = repo.join("a").join("b").join("c");
        fs::create_dir_all(&nested).unwrap();

        assert_eq!(find_git_root(&nested), Some(repo));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn find_git_root_treats_git_file_as_repo_marker() {
        let root = temp_root("git_root_worktree");
        let worktree = root.join("worktree");
        fs::create_dir_all(&worktree).unwrap();
        fs::write(
            worktree.join(GIT_DIR_NAME),
            "gitdir: /elsewhere/.git/worktrees/wt\n",
        )
        .unwrap();

        assert_eq!(find_git_root(&worktree), Some(worktree));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn find_git_root_returns_none_when_no_git() {
        let root = temp_root("git_root_missing");
        let bare = root.join("bare");
        fs::create_dir_all(&bare).unwrap();

        assert_eq!(find_git_root(&bare), None);

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_creates_structured_layout_and_index() {
        let root = temp_root("bootstrap_layout");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();

        let mem_dir = bootstrap_workspace_memory(&repo).unwrap();

        assert_eq!(mem_dir, paths::workspace_memory_dir_for(&repo));
        assert!(mem_dir.is_dir());
        let index = mem_dir.join(MEMORY_INDEX_FILE_NAME);
        assert!(index.exists());
        let body = fs::read_to_string(&index).unwrap();
        assert!(body.starts_with("# Workspace Memory Index"));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_creates_gitignore_when_absent() {
        let root = temp_root("bootstrap_gi_new");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();

        bootstrap_workspace_memory(&repo).unwrap();

        let gi = repo.join(GITIGNORE_FILE_NAME);
        assert!(gi.exists());
        let body = fs::read_to_string(&gi).unwrap();
        assert!(body.contains(".coyote/memory/"));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_appends_to_existing_gitignore_without_trailing_newline() {
        let root = temp_root("bootstrap_gi_append");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
        fs::write(repo.join(GITIGNORE_FILE_NAME), "target/").unwrap();

        bootstrap_workspace_memory(&repo).unwrap();

        let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
        assert!(body.contains("target/"));
        assert!(body.contains(".coyote/memory/"));
        assert!(body.ends_with('\n'));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_is_idempotent_on_gitignore_entry() {
        let root = temp_root("bootstrap_gi_idempotent");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
        let original = "target/\n.coyote/memory/\n";
        fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();

        bootstrap_workspace_memory(&repo).unwrap();

        let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
        assert_eq!(body, original, "gitignore must be untouched");

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_treats_entry_without_trailing_slash_as_present() {
        let root = temp_root("bootstrap_gi_no_slash");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
        let original = ".coyote/memory\n";
        fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();

        bootstrap_workspace_memory(&repo).unwrap();

        let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
        assert_eq!(body, original);

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn bootstrap_does_not_clobber_existing_index() {
        let root = temp_root("bootstrap_existing_index");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
        let mem_dir = paths::workspace_memory_dir_for(&repo);
        fs::create_dir_all(&mem_dir).unwrap();
        let preserved = "# Custom Index\n\n- [[foo]]: keep me\n";
        fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), preserved).unwrap();

        bootstrap_workspace_memory(&repo).unwrap();

        let body = fs::read_to_string(mem_dir.join(MEMORY_INDEX_FILE_NAME)).unwrap();
        assert_eq!(body, preserved);

        let _ = fs::remove_dir_all(&root);
    }
}
