use crate::{
    invocation::{SkillContext, SkillInput},
    outcome::SkillOutcome,
    skill::{Skill, SkillId},
};
use serde_json::json;

/// Harvest: read multiple files in one MCP call with optional per-file compression.
/// Saves round-trips when an agent needs context from several files simultaneously.
pub struct HarvestSkill;

impl Skill for HarvestSkill {
    fn id(&self) -> SkillId {
        SkillId("harvest")
    }
    fn display_name(&self) -> &str {
        "Harvest"
    }

    fn input_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "paths": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "File paths to read (relative or absolute). Up to 50 files per call."
                },
                "lens": {
                    "type": "string",
                    "enum": ["none", "signatures", "map", "aggressive", "auto"],
                    "default": "none",
                    "description": "Compression lens to apply to each file. none returns raw content."
                },
                "budget_tokens": {
                    "type": "integer",
                    "default": 8000,
                    "description": "Total token budget split evenly across all files. Overflow is truncated."
                },
                "skip_binary": {
                    "type": "boolean",
                    "default": true,
                    "description": "Skip files that appear to be binary (non-UTF-8)"
                },
                "include_errors": {
                    "type": "boolean",
                    "default": false,
                    "description": "Include file-level errors in results instead of silently skipping"
                }
            },
            "required": ["paths"]
        })
    }

    fn output_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "files": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "path":         { "type": "string" },
                            "content":      { "type": "string" },
                            "tokens":       { "type": "integer" },
                            "tokens_raw":   { "type": "integer" },
                            "compressed":   { "type": "boolean" },
                            "error":        { "type": "string" }
                        }
                    }
                },
                "files_read":     { "type": "integer" },
                "files_skipped":  { "type": "integer" },
                "tokens_total":   { "type": "integer" },
                "tokens_raw_total": { "type": "integer" },
                "savings_pct":    { "type": "number" }
            }
        })
    }

    fn execute(&self, input: SkillInput, _ctx: &SkillContext) -> anyhow::Result<SkillOutcome> {
        let paths: Vec<String> = input
            .get_str_array("paths")
            .unwrap_or_default()
            .into_iter()
            .take(50)
            .collect();

        if paths.is_empty() {
            return Ok(SkillOutcome::failure(
                "harvest",
                "paths array is required and must not be empty",
            ));
        }

        let lens = input.get_str("lens").unwrap_or("none").to_string();
        let budget_tokens = input.get_u64("budget_tokens").unwrap_or(8000) as usize;
        let skip_binary = input.get_bool("skip_binary").unwrap_or(true);
        let include_errors = input.get_bool("include_errors").unwrap_or(false);

        let per_file_budget = budget_tokens
            .checked_div(paths.len())
            .unwrap_or(budget_tokens);

        let mut results: Vec<serde_json::Value> = Vec::new();
        let mut files_read = 0usize;
        let mut files_skipped = 0usize;
        let mut tokens_total = 0usize;
        let mut tokens_raw_total = 0usize;

        for path in &paths {
            match std::fs::read_to_string(path) {
                Err(e) => {
                    files_skipped += 1;
                    if include_errors {
                        results.push(json!({
                            "path": path,
                            "content": null,
                            "tokens": 0,
                            "tokens_raw": 0,
                            "compressed": false,
                            "error": e.to_string()
                        }));
                    }
                }
                Ok(raw) => {
                    // Skip binary files that somehow passed read_to_string (rare — check for null bytes)
                    if skip_binary && raw.contains('\0') {
                        files_skipped += 1;
                        if include_errors {
                            results.push(json!({
                                "path": path,
                                "content": null,
                                "tokens": 0,
                                "tokens_raw": 0,
                                "compressed": false,
                                "error": "binary file skipped"
                            }));
                        }
                        continue;
                    }

                    let tokens_raw = estimate_tokens(&raw);
                    tokens_raw_total += tokens_raw;

                    let (content, compressed) = match lens.as_str() {
                        "signatures" => (apply_signatures_lens(&raw), true),
                        "map" => (apply_map_lens(&raw), true),
                        "aggressive" => (apply_aggressive_lens(&raw, per_file_budget), true),
                        "auto" => {
                            if tokens_raw > per_file_budget {
                                (apply_aggressive_lens(&raw, per_file_budget), true)
                            } else {
                                (raw.clone(), false)
                            }
                        }
                        _ => (raw.clone(), false), // "none"
                    };

                    // Hard cap at per_file_budget
                    let content = cap_to_token_budget(content, per_file_budget);
                    let tokens = estimate_tokens(&content);
                    tokens_total += tokens;
                    files_read += 1;

                    results.push(json!({
                        "path": path,
                        "content": content,
                        "tokens": tokens,
                        "tokens_raw": tokens_raw,
                        "compressed": compressed,
                        "error": null
                    }));
                }
            }
        }

        let savings_pct = if tokens_raw_total > 0 {
            let saved = tokens_raw_total.saturating_sub(tokens_total);
            (saved * 100).checked_div(tokens_raw_total).unwrap_or(0) as f64
        } else {
            0.0
        };

        Ok(SkillOutcome::success(
            "harvest",
            json!({
                "files": results,
                "files_read": files_read,
                "files_skipped": files_skipped,
                "tokens_total": tokens_total,
                "tokens_raw_total": tokens_raw_total,
                "savings_pct": savings_pct
            }),
        ))
    }
}

fn estimate_tokens(s: &str) -> usize {
    s.len().div_ceil(4)
}

fn apply_signatures_lens(content: &str) -> String {
    // Keep only definition lines (fn, struct, class, def, etc.)
    content
        .lines()
        .filter(|l| {
            let t = l.trim();
            t.starts_with("pub fn ")
                || t.starts_with("fn ")
                || t.starts_with("pub struct ")
                || t.starts_with("struct ")
                || t.starts_with("pub enum ")
                || t.starts_with("enum ")
                || t.starts_with("pub trait ")
                || t.starts_with("trait ")
                || t.starts_with("pub type ")
                || t.starts_with("type ")
                || t.starts_with("def ")
                || t.starts_with("class ")
                || t.starts_with("function ")
                || t.starts_with("export function ")
                || t.starts_with("export class ")
                || t.starts_with("export const ")
                || t.starts_with("export type ")
                || t.starts_with("export interface ")
                || t.starts_with("interface ")
                || t.starts_with("func ")
                || t.starts_with("/// ")
                || t.starts_with("//!")
                || t.starts_with("#[") // Rust attributes (for #[derive], #[route], etc.)
                || t.is_empty()
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn apply_map_lens(content: &str) -> String {
    // Strip function bodies: keep lines up to opening brace + closing brace
    let mut out = Vec::new();
    let mut depth = 0i32;
    let mut in_body = false;

    for line in content.lines() {
        let opens = line.chars().filter(|&c| c == '{').count() as i32;
        let closes = line.chars().filter(|&c| c == '}').count() as i32;

        if !in_body {
            out.push(line.to_string());
            depth += opens - closes;
            if depth > 0 {
                in_body = true;
            }
        } else {
            depth += opens - closes;
            if depth <= 0 {
                out.push("}".to_string());
                in_body = false;
                depth = 0;
            }
        }
    }
    out.join("\n")
}

fn apply_aggressive_lens(content: &str, budget: usize) -> String {
    // Remove blank lines and comments, then truncate to budget
    let filtered: Vec<&str> = content
        .lines()
        .filter(|l| {
            let t = l.trim();
            !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
        })
        .collect();
    cap_to_token_budget(filtered.join("\n"), budget)
}

fn cap_to_token_budget(s: String, budget: usize) -> String {
    let limit_chars = budget * 4;
    if s.len() <= limit_chars {
        return s;
    }
    // Truncate at char boundary
    let mut end = limit_chars;
    while !s.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}\n… [truncated to {} token budget]", &s[..end], budget)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::invocation::{SkillContext, SkillInput};
    use tempfile::tempdir;

    fn ctx() -> SkillContext {
        SkillContext::new("test", ".")
    }

    #[test]
    fn reads_two_files_no_lens() {
        let dir = tempdir().unwrap();
        let f1 = dir.path().join("a.txt");
        let f2 = dir.path().join("b.txt");
        std::fs::write(&f1, "hello").unwrap();
        std::fs::write(&f2, "world").unwrap();

        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({ "paths": [f1.to_str().unwrap(), f2.to_str().unwrap()] })),
                &ctx(),
            )
            .unwrap();
        assert_eq!(out.result["files_read"], 2);
        assert_eq!(out.result["files_skipped"], 0);
    }

    #[test]
    fn missing_file_counted_as_skipped() {
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({ "paths": ["/nonexistent/missing.txt"] })),
                &ctx(),
            )
            .unwrap();
        assert!(out.result["error"].is_null());
        assert_eq!(out.result["files_skipped"], 1);
        assert_eq!(out.result["files_read"], 0);
    }

    #[test]
    fn missing_file_included_with_flag() {
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({
                    "paths": ["/nonexistent/missing.txt"],
                    "include_errors": true
                })),
                &ctx(),
            )
            .unwrap();
        let err_str = out.result["files"][0]["error"].as_str().unwrap_or("");
        assert!(!err_str.is_empty());
    }

    #[test]
    fn signatures_lens_keeps_fn_lines() {
        let content = "fn foo() {\n    let x = 1;\n}\nfn bar() {}";
        let result = apply_signatures_lens(content);
        assert!(result.contains("fn foo()"));
        assert!(result.contains("fn bar()"));
        assert!(!result.contains("let x = 1"));
    }

    #[test]
    fn aggressive_lens_removes_blank_and_comments() {
        let content = "// comment\n\nfn foo() {}\n// another\nlet x = 1;";
        let result = apply_aggressive_lens(content, 10000);
        assert!(!result.contains("// comment"));
        assert!(result.contains("fn foo()"));
        assert!(result.contains("let x = 1;"));
    }

    #[test]
    fn token_budget_caps_output() {
        let large = "a".repeat(1000);
        let capped = cap_to_token_budget(large, 10);
        assert!(capped.len() < 200);
        assert!(capped.contains("truncated"));
    }

    #[test]
    fn estimate_tokens_approximation() {
        assert_eq!(estimate_tokens("hello"), 2);
        assert_eq!(estimate_tokens(""), 0);
    }

    #[test]
    fn empty_paths_returns_failure() {
        let skill = HarvestSkill;
        let out = skill
            .execute(SkillInput::new(json!({ "paths": [] })), &ctx())
            .unwrap();
        assert!(out.result["error"].as_str().is_some());
    }

    #[test]
    fn savings_pct_computed() {
        let dir = tempdir().unwrap();
        let f = dir.path().join("big.rs");
        let content = (0..100)
            .map(|i| format!("// comment {i}\nfn func_{i}() {{}}\n"))
            .collect::<String>();
        std::fs::write(&f, &content).unwrap();
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({
                    "paths": [f.to_str().unwrap()],
                    "lens": "signatures"
                })),
                &ctx(),
            )
            .unwrap();
        assert!(out.result["savings_pct"].as_f64().unwrap() > 0.0);
    }

    #[test]
    fn map_lens_strips_function_bodies() {
        let dir = tempdir().unwrap();
        let f = dir.path().join("code.rs");
        std::fs::write(
            &f,
            "fn foo() {\n    let x = 1;\n    let y = 2;\n}\nfn bar() {}\n",
        )
        .unwrap();
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({
                    "paths": [f.to_str().unwrap()],
                    "lens": "map"
                })),
                &ctx(),
            )
            .unwrap();
        let content = out.result["files"][0]["content"].as_str().unwrap_or("");
        assert!(content.contains("fn foo()"));
        assert!(!content.contains("let x = 1"), "body should be stripped");
        assert!(out.result["files"][0]["compressed"]
            .as_bool()
            .unwrap_or(false));
    }

    #[test]
    fn auto_lens_passes_through_small_file() {
        let dir = tempdir().unwrap();
        let f = dir.path().join("tiny.rs");
        std::fs::write(&f, "fn main() {}\n").unwrap();
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({
                    "paths": [f.to_str().unwrap()],
                    "lens": "auto",
                    "budget_tokens": 8000
                })),
                &ctx(),
            )
            .unwrap();
        // Small file fits in budget — no compression
        assert!(!out.result["files"][0]["compressed"]
            .as_bool()
            .unwrap_or(true));
    }

    #[test]
    fn auto_lens_compresses_large_file() {
        let dir = tempdir().unwrap();
        let f = dir.path().join("large.rs");
        // Generate content that exceeds a tiny budget
        let big = (0..200)
            .map(|i| format!("fn func_{i}() {{ let x = {i}; }}\n"))
            .collect::<String>();
        std::fs::write(&f, &big).unwrap();
        let skill = HarvestSkill;
        let out = skill
            .execute(
                SkillInput::new(json!({
                    "paths": [f.to_str().unwrap()],
                    "lens": "auto",
                    "budget_tokens": 10  // tiny budget forces compression
                })),
                &ctx(),
            )
            .unwrap();
        // Either compressed or truncated — either way tokens_total <= budget * some margin
        let tokens_total = out.result["tokens_total"].as_u64().unwrap_or(0);
        assert!(
            tokens_total <= 50,
            "expected aggressive compression, got {tokens_total} tokens"
        );
    }
}
