use anyhow::{Context, Result};
use globset::GlobBuilder;
use ignore::WalkBuilder;
use reqwest::blocking::Client;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

const INSTRUCTION_FILES: &[&str] = &[
    "AGENTS.md",
    "CLAUDE.md",
    ".github/copilot-instructions.md",
    "CONTEXT.md",
];

/// Find instruction files by walking up from a file path.
/// Similar to opencode's Instruction.resolve() logic.
/// Returns Vec of (filepath, content) for nearby instruction files.
pub fn resolve_nearby_instructions(
    workspace_root: &Path,
    config_dir: &Path,
    file_path: &Path,
) -> Result<Vec<(PathBuf, String)>> {
    let mut results = Vec::new();
    let mut seen = HashSet::new();

    let target = file_path
        .canonicalize()
        .unwrap_or_else(|_| file_path.to_path_buf());
    let root = workspace_root
        .canonicalize()
        .unwrap_or_else(|_| workspace_root.to_path_buf());

    // Collect system-wide instruction paths
    let system_paths = system_paths(workspace_root, config_dir, &[])?;
    let system_set: HashSet<_> = system_paths
        .iter()
        .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
        .collect();

    // Walk upward from the file being read
    let mut current = target.parent().unwrap_or(&target);
    while current.starts_with(&root) {
        for file_name in INSTRUCTION_FILES {
            let candidate = current.join(file_name);
            let canonical = candidate
                .canonicalize()
                .unwrap_or_else(|_| candidate.clone());

            // Skip if already loaded, system-wide, or the file itself
            if canonical == target {
                continue;
            }
            if system_set.contains(&canonical) {
                continue;
            }
            if seen.contains(&canonical) {
                continue;
            }

            if candidate.exists()
                && let Ok(content) = fs::read_to_string(&candidate)
                && !content.trim().is_empty()
            {
                seen.insert(canonical);
                results.push((
                    candidate.clone(),
                    format!("Instructions from: {}\n{}", candidate.display(), content),
                ));
            }
        }

        if current == root {
            break;
        }
        current = current.parent().unwrap_or(current);
    }

    Ok(results)
}

pub fn system_paths(
    workspace_root: &Path,
    config_dir: &Path,
    instructions: &[String],
) -> Result<Vec<PathBuf>> {
    let mut paths = Vec::new();
    let mut seen = HashSet::new();

    let mut push_unique = |path: PathBuf| {
        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
        if seen.insert(canonical.clone()) {
            paths.push(canonical);
        }
    };

    if let Some(project_path) = find_project_instruction(workspace_root)? {
        push_unique(project_path);
    }

    let global_path = config_dir.join("AGENTS.md");
    if global_path.exists() {
        push_unique(global_path);
    }

    for raw in instructions {
        if raw.starts_with("http://") || raw.starts_with("https://") {
            continue;
        }

        let resolved = resolve_instruction_paths(workspace_root, raw)?;
        for path in resolved {
            push_unique(path);
        }
    }

    Ok(paths)
}

pub fn system_prompt_and_sources(
    workspace_root: &Path,
    config_dir: &Path,
    instructions: &[String],
) -> Result<(String, Vec<String>)> {
    let mut sections = Vec::new();
    let mut sources = Vec::new();
    let paths = system_paths(workspace_root, config_dir, instructions)?;

    for path in paths {
        if let Ok(content) = fs::read_to_string(&path)
            && !content.trim().is_empty()
        {
            sections.push(format!(
                "Instructions from: {}\n{}",
                path.display(),
                content
            ));
            sources.push(path.display().to_string());
        }
    }

    for url in instructions
        .iter()
        .filter(|item| item.starts_with("http://") || item.starts_with("https://"))
    {
        if let Ok(content) = fetch_remote(url)
            && !content.trim().is_empty()
        {
            sections.push(format!("Instructions from: {}\n{}", url, content));
            sources.push(url.clone());
        }
    }

    Ok((sections.join("\n\n"), sources))
}

/// Build system prompt with content caching to avoid redundant file I/O.
/// Only reads files that are not already in the cache.
/// Returns (prompt, sources, updated_cache)
pub fn system_prompt_and_sources_with_cache(
    workspace_root: &Path,
    config_dir: &Path,
    instructions: &[String],
    cache: &HashMap<String, String>,
) -> Result<(String, Vec<String>, HashMap<String, String>)> {
    let mut sections = Vec::new();
    let mut sources = Vec::new();
    let mut new_cache = cache.clone();
    let paths = system_paths(workspace_root, config_dir, instructions)?;

    for path in paths {
        let path_str = path.display().to_string();
        if let Some(cached_content) = cache.get(&path_str) {
            // Use cached content
            if !cached_content.trim().is_empty() {
                sections.push(format!(
                    "Instructions from: {}\n{}",
                    path.display(),
                    cached_content
                ));
                sources.push(path_str);
            }
        } else {
            // Read and cache new content
            if let Ok(content) = fs::read_to_string(&path)
                && !content.trim().is_empty()
            {
                new_cache.insert(path_str.clone(), content.clone());
                sections.push(format!(
                    "Instructions from: {}\n{}",
                    path.display(),
                    content
                ));
                sources.push(path_str);
            }
        }
    }

    for url in instructions
        .iter()
        .filter(|item| item.starts_with("http://") || item.starts_with("https://"))
    {
        if let Some(cached_content) = cache.get(url) {
            // Use cached content
            if !cached_content.trim().is_empty() {
                sections.push(format!("Instructions from: {}\n{}", url, cached_content));
                sources.push(url.clone());
            }
        } else {
            // Fetch and cache new content
            if let Ok(content) = fetch_remote(url)
                && !content.trim().is_empty()
            {
                new_cache.insert(url.clone(), content.clone());
                sections.push(format!("Instructions from: {}\n{}", url, content));
                sources.push(url.clone());
            }
        }
    }

    Ok((sections.join("\n\n"), sources, new_cache))
}

pub fn system_prompt(
    workspace_root: &Path,
    config_dir: &Path,
    instructions: &[String],
) -> Result<String> {
    Ok(system_prompt_and_sources(workspace_root, config_dir, instructions)?.0)
}

fn find_project_instruction(workspace_root: &Path) -> Result<Option<PathBuf>> {
    for ancestor in workspace_root.ancestors() {
        for file_name in INSTRUCTION_FILES {
            let candidate = ancestor.join(file_name);
            if candidate.exists() {
                return Ok(Some(candidate));
            }
        }
    }
    Ok(None)
}

fn resolve_instruction_paths(workspace_root: &Path, raw: &str) -> Result<Vec<PathBuf>> {
    let raw = raw.trim();
    if raw.is_empty() {
        return Ok(Vec::new());
    }

    let raw = if let Some(stripped) = raw.strip_prefix("~/") {
        dirs::home_dir()
            .map(|dir| dir.join(stripped))
            .unwrap_or_else(|| PathBuf::from(raw))
    } else {
        PathBuf::from(raw)
    };

    if raw.is_absolute() {
        if contains_glob(&raw) {
            return glob_absolute(&raw);
        }

        if raw.exists() {
            return Ok(vec![raw]);
        }

        return Ok(Vec::new());
    }

    if contains_glob(&raw) {
        return glob_relative(workspace_root, &raw);
    }

    let candidate = workspace_root.join(&raw);
    if candidate.exists() {
        return Ok(vec![candidate]);
    }

    Ok(Vec::new())
}

fn contains_glob(path: &Path) -> bool {
    let text = path.to_string_lossy();
    text.contains('*') || text.contains('?') || text.contains('[')
}

fn glob_relative(workspace_root: &Path, pattern: &Path) -> Result<Vec<PathBuf>> {
    let matcher = GlobBuilder::new(&pattern.to_string_lossy())
        .literal_separator(false)
        .build()
        .context("invalid glob pattern")?
        .compile_matcher();

    let mut results = Vec::new();
    let walker = WalkBuilder::new(workspace_root)
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .follow_links(false)
        .build();

    for entry in walker {
        let entry = match entry {
            Ok(entry) => entry,
            Err(_) => continue,
        };

        if !entry
            .file_type()
            .map(|file_type| file_type.is_file())
            .unwrap_or(false)
        {
            continue;
        }

        let path = entry.path();
        if let Ok(rel) = path.strip_prefix(workspace_root) {
            let candidate = rel.to_string_lossy();
            if matcher.is_match(&*candidate) {
                results.push(path.to_path_buf());
            }
        }
    }

    results.sort();
    Ok(results)
}

fn glob_absolute(pattern: &Path) -> Result<Vec<PathBuf>> {
    let matcher = GlobBuilder::new(&pattern.to_string_lossy())
        .literal_separator(false)
        .build()
        .context("invalid glob pattern")?
        .compile_matcher();

    let mut results = Vec::new();
    let root = Path::new("/");
    let walker = WalkBuilder::new(root)
        .hidden(false)
        .git_ignore(true)
        .git_global(true)
        .git_exclude(true)
        .follow_links(false)
        .build();

    for entry in walker {
        let entry = match entry {
            Ok(entry) => entry,
            Err(_) => continue,
        };

        if !entry
            .file_type()
            .map(|file_type| file_type.is_file())
            .unwrap_or(false)
        {
            continue;
        }

        let path = entry.path();
        let candidate = path.to_string_lossy();
        if matcher.is_match(&*candidate) {
            results.push(path.to_path_buf());
        }
    }

    results.sort();
    Ok(results)
}

fn fetch_remote(url: &str) -> Result<String> {
    let client = Client::builder()
        .timeout(Duration::from_secs(5))
        .build()
        .context("failed to build http client")?;

    let response = client
        .get(url)
        .send()
        .context("failed to fetch remote instruction")?;

    let status = response.status();
    if !status.is_success() {
        return Ok(String::new());
    }

    response
        .text()
        .context("failed to read remote instruction body")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;
    use uuid::Uuid;

    fn make_temp_dir() -> Result<PathBuf> {
        let dir = std::env::temp_dir().join(format!("tidev-instructions-{}", Uuid::new_v4()));
        fs::create_dir_all(&dir).context("failed to create temp dir")?;
        Ok(dir)
    }

    #[test]
    fn system_paths_finds_project_agent_file() -> Result<()> {
        let workspace = make_temp_dir()?;
        fs::write(workspace.join("AGENTS.md"), "# Root")?;

        let paths = system_paths(&workspace, &workspace, &[])?;
        assert_eq!(paths, vec![workspace.join("AGENTS.md").canonicalize()?]);
        Ok(())
    }

    #[test]
    fn system_paths_prefers_project_over_global() -> Result<()> {
        let workspace = make_temp_dir()?;
        let global = make_temp_dir()?;

        fs::write(workspace.join("AGENTS.md"), "# Root")?;
        fs::write(global.join("AGENTS.md"), "# Global")?;

        let paths = system_paths(&workspace, &global, &[])?;
        assert_eq!(
            paths,
            vec![
                workspace.join("AGENTS.md").canonicalize()?,
                global.join("AGENTS.md").canonicalize()?,
            ],
        );
        Ok(())
    }

    #[test]
    fn system_prompt_loads_config_instructions() -> Result<()> {
        let workspace = make_temp_dir()?;
        let global = make_temp_dir()?;
        let extra = workspace.join("docs");
        fs::create_dir_all(&extra)?;
        fs::write(extra.join("style.md"), "# Style")?;

        let prompt = system_prompt(&workspace, &global, &["docs/style.md".to_string()])?;
        assert!(prompt.contains("Instructions from:"));
        assert!(prompt.contains("# Style"));
        Ok(())
    }

    #[test]
    fn system_paths_finds_github_copilot_instructions() -> Result<()> {
        let workspace = make_temp_dir()?;
        fs::create_dir_all(workspace.join(".github"))?;
        fs::write(
            workspace.join(".github").join("copilot-instructions.md"),
            "# Copilot",
        )?;

        let paths = system_paths(&workspace, &workspace, &[])?;
        assert_eq!(
            paths,
            vec![
                workspace
                    .join(".github")
                    .join("copilot-instructions.md")
                    .canonicalize()?
            ]
        );
        Ok(())
    }

    #[test]
    fn resolve_nearby_instructions_finds_github_copilot_instructions() -> Result<()> {
        let workspace = make_temp_dir()?;
        let subdir = workspace.join("subdir").join("nested");
        fs::create_dir_all(&subdir)?;
        // Put the instruction file in a subdirectory, not in workspace root
        // to avoid being excluded by find_project_instruction
        fs::create_dir_all(subdir.join(".github"))?;
        fs::write(
            subdir.join(".github").join("copilot-instructions.md"),
            "# Copilot",
        )?;
        fs::write(subdir.join("file.rs"), "let x = 1;")?;

        // Use a config_dir outside the workspace to avoid system path conflicts
        let config_dir = std::env::temp_dir().join("tidev-test-config-unique");
        fs::create_dir_all(&config_dir)?;
        let results =
            resolve_nearby_instructions(&workspace, &config_dir, &subdir.join("file.rs"))?;

        let expected_path = subdir
            .join(".github")
            .join("copilot-instructions.md")
            .canonicalize()?;
        // Use canonicalized path for content to match the function's output
        let expected_content = format!(
            "Instructions from: {}\n{}",
            expected_path.display(),
            "# Copilot"
        );
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, expected_path);
        assert_eq!(results[0].1, expected_content);
        Ok(())
    }

    #[test]
    fn resolve_nearby_instructions_finds_subdirectory_agents() -> Result<()> {
        let workspace = make_temp_dir()?;
        let subdir = workspace.join("subdir").join("nested");
        fs::create_dir_all(&subdir)?;
        fs::write(workspace.join("subdir").join("AGENTS.md"), "# Subdir")?;
        fs::write(subdir.join("file.rs"), "let x = 1;")?;

        // Use a config_dir outside the workspace to avoid system path conflicts
        let config_dir = std::env::temp_dir().join("tidev-test-config-unique");
        fs::create_dir_all(&config_dir)?;
        let results =
            resolve_nearby_instructions(&workspace, &config_dir, &subdir.join("file.rs"))?;
        let expected_path = workspace.join("subdir").join("AGENTS.md").canonicalize()?;
        // Use canonicalized path for content format to match
        let expected_content = format!(
            "Instructions from: {}\n{}",
            expected_path.display(),
            "# Subdir"
        );
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, expected_path);
        assert_eq!(results[0].1, expected_content);
        Ok(())
    }
}
