use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use anyhow::{bail, Context, Result};
use regex::Regex;

use crate::config::AgentsConfig;

static MARKDOWN_LINK_PATTERN: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("invalid markdown link regex"));

/// Structured representation of agent documentation output.
#[derive(Debug, Clone)]
pub struct AgentBundle {
    /// Optional top-level instructions template overriding the default markdown.
    pub template: Option<String>,
    /// Placeholder substitutions applied to the instructions template.
    pub format: HashMap<String, String>,
    /// Summaries extracted from individual agent markdown files.
    pub entries: Vec<AgentEntry>,
    /// Flags that control which optional sections are rendered.
    pub options: AgentBundleOptions,
    /// Filesystem location of the agent documentation sources.
    pub source_dir: PathBuf,
    /// Display path used when referencing agent files in rendered output.
    pub source_dir_display: String,
    /// Display path to the docs root used when normalizing intra-doc links.
    pub docs_root_display: String,
}

/// Optional sections to include when rendering bundles.
#[derive(Debug, Clone)]
pub struct AgentBundleOptions {
    /// Whether rendered markdown should include the per-file summaries table.
    pub include_summary: bool,
    /// Whether to append a repository tree snapshot to the bundle.
    pub include_tree: bool,
    /// Glob patterns applied when generating the repository tree.
    pub tree_excludes: Vec<String>,
}

/// Metadata extracted from a single agent-facing markdown file.
#[derive(Debug, Clone)]
pub struct AgentEntry {
    /// Location of the source markdown file relative to `source_dir`.
    pub relative_path: PathBuf,
    /// Markdown describing scenarios that should refer to this document.
    pub read_when: String,
    /// Key topics or concepts summarized from the document.
    pub contains: String,
    /// Optional additional resources linked from the summary.
    pub related_files: Option<String>,
    /// Size metrics derived from the file contents.
    pub content_size: ContentSize,
}

/// Simple size metrics to help estimate embedding costs.
#[derive(Debug, Clone)]
pub struct ContentSize {
    /// Number of non-empty lines present in the source document.
    pub non_empty_lines: usize,
    /// Rough token count estimate computed from the character length.
    pub estimated_tokens: usize,
}

/// Build agent documentation bundle from configuration.
pub fn build_agents_bundle(
    docs_root: &Path,
    docs_root_display: &str,
    cfg: &AgentsConfig,
) -> Result<AgentBundle> {
    let source_root = cfg.source_dir.clone();
    if !source_root.exists() {
        bail!(
            "agents source directory `{}` does not exist",
            source_root.display()
        );
    }

    let mut entries = Vec::new();
    collect_agent_entries(
        &source_root,
        &source_root,
        docs_root,
        docs_root_display,
        &mut entries,
    )?;
    entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));

    Ok(AgentBundle {
        template: cfg.instructions.template.clone(),
        format: cfg.instructions.format.clone(),
        entries,
        options: AgentBundleOptions {
            include_summary: cfg.output.include_summary,
            include_tree: cfg.output.include_tree,
            tree_excludes: cfg.tree.exclude.clone(),
        },
        source_dir: cfg.source_dir.clone(),
        source_dir_display: cfg.source_dir_display.clone(),
        docs_root_display: docs_root_display.to_string(),
    })
}

/// Recursively scan the `source_root` directory for agent markdown files and populate `entries`.
fn collect_agent_entries(
    dir: &Path,
    source_root: &Path,
    docs_root: &Path,
    docs_root_display: &str,
    entries: &mut Vec<AgentEntry>,
) -> Result<()> {
    let mut children: Vec<PathBuf> = fs::read_dir(dir)
        .with_context(|| format!("failed to read directory `{}`", dir.display()))?
        .map(|res| res.map(|entry| entry.path()))
        .collect::<std::io::Result<Vec<_>>>()
        .with_context(|| format!("failed to enumerate `{}`", dir.display()))?;

    children.sort();

    for path in children {
        if path.is_dir() {
            collect_agent_entries(&path, source_root, docs_root, docs_root_display, entries)?;
            continue;
        }

        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
            continue;
        }

        let content = fs::read_to_string(&path)
            .with_context(|| format!("failed to read `{}`", path.display()))?;

        let summary = extract_summary_fields(&content, &path, docs_root, docs_root_display)?;
        let metrics = measure_content(&content);

        let relative_path = path
            .strip_prefix(source_root)
            .unwrap_or(&path)
            .to_path_buf();

        entries.push(AgentEntry {
            relative_path,
            read_when: summary.read_when,
            contains: summary.contains,
            related_files: summary.related_files,
            content_size: metrics,
        });
    }

    Ok(())
}

/// Parse the `<ai>` summary block from an agent markdown file and normalize internal links.
fn extract_summary_fields(
    content: &str,
    current_file: &Path,
    docs_root: &Path,
    docs_root_display: &str,
) -> Result<AgentSummaryFields> {
    let (section, start_offset) = find_ai_section(content)?;

    let read_when_raw = extract_xml_field(section, "read_when")?;
    let contains_raw = extract_xml_field(section, "contains")?;
    let related_raw = extract_optional_xml_field(section, "related_files");

    let current_abs = current_file
        .canonicalize()
        .unwrap_or_else(|_| current_file.to_path_buf());

    Ok(AgentSummaryFields {
        read_when: normalize_links(&read_when_raw, &current_abs, docs_root, docs_root_display),
        contains: normalize_links(&contains_raw, &current_abs, docs_root, docs_root_display),
        related_files: related_raw
            .map(|value| normalize_links(&value, &current_abs, docs_root, docs_root_display)),
        _section_offset: start_offset,
    })
}

#[derive(Debug)]
struct AgentSummaryFields {
    read_when: String,
    contains: String,
    related_files: Option<String>,
    #[allow(dead_code)]
    _section_offset: usize,
}

/// Locate the `<ai>` block in the document, returning the subsection and byte offset.
fn find_ai_section(content: &str) -> Result<(&str, usize)> {
    let start = content.find("<ai>").with_context(|| "missing `<ai>` tag")?;
    let end = content
        .find("</ai>")
        .with_context(|| "missing `</ai>` tag")?;

    if end < start {
        bail!("`</ai>` appears before `<ai>`");
    }

    Ok((&content[start + 4..end], start))
}

/// Extract a required child XML-like tag from the `<ai>` section.
fn extract_xml_field(section: &str, tag: &str) -> Result<String> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");

    let start = section
        .find(&open)
        .with_context(|| format!("Missing <{tag}> tag"))?;
    let end = section
        .find(&close)
        .with_context(|| format!("Missing </{tag}> tag"))?;

    if end < start {
        bail!("Closing </{tag}> tag precedes opening");
    }

    let value = &section[start + open.len()..end];
    let trimmed = value.trim();
    if trimmed.is_empty() {
        bail!("<{tag}> tag is empty");
    }

    Ok(trimmed.to_string())
}

/// Extract an optional child tag, returning `None` when the field is missing or empty.
fn extract_optional_xml_field(section: &str, tag: &str) -> Option<String> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");

    let start = section.find(&open)?;
    let end = section[start + open.len()..].find(&close)? + start + open.len();

    let value = &section[start + open.len()..end];
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

/// Rewrite markdown links so paths resolve relative to the rendered documentation root.
fn normalize_links(
    text: &str,
    current_file: &Path,
    docs_root: &Path,
    docs_root_display: &str,
) -> String {
    let mut result = String::new();
    let mut last_end = 0;

    for cap in MARKDOWN_LINK_PATTERN.captures_iter(text) {
        let full_match = cap.get(0).unwrap();
        let link_text = cap.get(1).unwrap().as_str();
        let link_target = cap.get(2).unwrap().as_str();

        result.push_str(&text[last_end..full_match.start()]);
        let new_target = rewrite_link(link_target, current_file, docs_root, docs_root_display)
            .unwrap_or_else(|| link_target.to_string());
        result.push_str(&format!("[{link_text}]({new_target})"));
        last_end = full_match.end();
    }

    result.push_str(&text[last_end..]);
    result
}

/// Rewrite a single markdown link target to use workspace-relative paths when possible.
fn rewrite_link(
    link: &str,
    current_file: &Path,
    docs_root: &Path,
    docs_root_display: &str,
) -> Option<String> {
    if link.starts_with("http://") || link.starts_with("https://") || link.starts_with('#') {
        return Some(link.to_string());
    }

    let cleaned = link.strip_prefix("./").unwrap_or(link);

    let current_dir = current_file.parent()?;
    let candidates = [current_dir.join(cleaned), docs_root.join(cleaned)];

    for candidate in &candidates {
        if candidate.exists() {
            if let Ok(rel) = candidate.strip_prefix(docs_root) {
                return Some(format!("{docs_root_display}/{}", to_forward_slashes(rel)));
            }
        }
    }

    for candidate in &candidates {
        let normalized = normalize_path(candidate);
        if let Ok(rel) = normalized.strip_prefix(docs_root) {
            return Some(format!("{docs_root_display}/{}", to_forward_slashes(rel)));
        }
    }

    None
}

/// Normalize a filesystem path by collapsing `.` and `..` components.
fn normalize_path(path: &Path) -> PathBuf {
    let mut components = Vec::new();
    for component in path.components() {
        match component {
            std::path::Component::ParentDir => {
                components.pop();
            }
            std::path::Component::CurDir => {}
            other => components.push(other),
        }
    }

    components.iter().collect()
}

/// Convert a path to a display string with forward slashes.
fn to_forward_slashes(path: &Path) -> String {
    let s = path.to_string_lossy();
    s.replace('\\', "/")
}

/// Compute rough size metrics from markdown content for reporting purposes.
fn measure_content(content: &str) -> ContentSize {
    let non_empty_lines = content
        .lines()
        .filter(|line| !line.trim().is_empty())
        .count();
    let char_count = content.chars().count();
    let estimated_tokens = char_count / 4;

    ContentSize {
        non_empty_lines,
        estimated_tokens,
    }
}

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

    #[test]
    fn normalize_links_http_passthrough() {
        let tmp = tempfile::tempdir().unwrap();
        let docs_src = tmp.path().join("docs/src");
        fs::create_dir_all(&docs_src).unwrap();
        let file = docs_src.join("agents/foo.md");
        fs::create_dir_all(file.parent().unwrap()).unwrap();
        fs::write(&file, "test").unwrap();

        let text = "See [Example](https://example.com).";
        let normalized = normalize_links(text, &file, &docs_src, "docs/src");
        assert_eq!(normalized, text);
    }

    #[test]
    fn measure_content_counts_lines_and_tokens() {
        let content = "line1\n\nline2\n line3 \n";
        let size = measure_content(content);
        assert_eq!(size.non_empty_lines, 3);
        assert!(size.estimated_tokens > 0);
    }

    #[test]
    fn extract_summary_fields_rejects_missing_tags() {
        let content = "# Title\n\n<ai>\n<read_when>something</read_when>\n</ai>";
        let tmp = tempfile::tempdir().unwrap();
        let file = tmp.path().join("foo.md");
        fs::write(&file, content).unwrap();
        let docs_src = tmp.path().join("docs/src");
        fs::create_dir_all(&docs_src).unwrap();

        let err = extract_summary_fields(content, &file, &docs_src, "docs/src").unwrap_err();
        assert!(format!("{err:#}").contains("Missing <contains>"));
    }

    #[test]
    fn build_agents_bundle_collects_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let workspace = tmp.path();
        let agents_dir = workspace.join("docs/src/agents");
        fs::create_dir_all(&agents_dir).unwrap();

        let doc = indoc! {r#"
            Title

            <ai>
            <read_when>When [needed](./foo.md)</read_when>
            <contains>- Item</contains>
            <related_files>docs/src/overview.md</related_files>
            </ai>
        "#};
        fs::write(agents_dir.join("auth.md"), doc).unwrap();
        fs::write(workspace.join("docs/src/foo.md"), "# Foo").unwrap();

        let source_dir = workspace.join("docs/src/agents");
        let cfg = AgentsConfig {
            source_dir: source_dir.clone(),
            source_dir_display: "docs/src/agents".to_string(),
            instructions: crate::config::AgentsInstructions {
                template: None,
                format: HashMap::new(),
            },
            output: crate::config::AgentsOutput {
                include_summary: true,
                include_tree: true,
            },
            tree: crate::config::AgentsTreeConfig {
                exclude: Vec::new(),
            },
        };

        let docs_root = workspace.join("docs/src");
        let bundle = build_agents_bundle(&docs_root, "docs/src", &cfg).expect("bundle");
        assert_eq!(bundle.entries.len(), 1);
        assert_eq!(bundle.source_dir, source_dir);
        assert_eq!(bundle.source_dir_display, "docs/src/agents");
        assert_eq!(bundle.docs_root_display, "docs/src");
        let entry = &bundle.entries[0];
        assert_eq!(entry.relative_path, PathBuf::from("auth.md"));
        assert!(entry.read_when.contains("docs/src/foo.md"));
    }
}
