use anyhow::{anyhow, Result};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{
    runtime::RuntimeHandle,
    system::ExecutionScopeKind,
    tool::{
        apply_patch::{self, ApplyPatchSurface},
        helpers::{invalid_tool_input, truncate_text, validate_non_empty},
        spec::{ToolFreeformGrammar, ToolResultStatus},
        ToolError, ToolResult,
    },
    types::{
        ApplyPatchAction, ApplyPatchDiagnostic, ApplyPatchResult, ToolCapabilityFamily, TrustLevel,
    },
};

use super::{serialize_success, BuiltinToolDefinition};
use crate::tool::{helpers::parse_tool_args, schema::tool_input_schema};

pub(crate) const NAME: &str = "ApplyPatch";
// Grammar matches OpenAI Codex's ApplyPatch DSL surface; Holon still applies
// parsed edits through its own workspace guards and atomic apply path.
const CODEX_DSL_LARK_GRAMMAR: &str = include_str!("apply_patch_tool_codex.lark");
const MODEL_ERROR_TEXT_LIMIT: usize = 700;
const MODEL_ERROR_TOKEN_LIMIT: usize = 96;
const MODEL_DIAGNOSTIC_LIMIT: usize = 8;
const SUMMARY_DIAGNOSTIC_LIMIT: usize = 3;

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ApplyPatchArgs {
    pub(crate) patch: String,
}

pub(crate) fn definition() -> Result<BuiltinToolDefinition> {
    definition_for_surface(ApplyPatchSurface::UnifiedDiffJson)
}

pub(crate) fn definition_for_surface(surface: ApplyPatchSurface) -> Result<BuiltinToolDefinition> {
    let (description, input_schema, freeform_grammar) = match surface {
        ApplyPatchSurface::CodexDslFreeform => (
            "Apply a Codex-style patch DSL across one or more files. Submit raw *** Begin Patch / *** End Patch text directly as the freeform tool body; do not wrap it in JSON.".to_string(),
            serde_json::json!({
                "type": "string",
                "description": "Raw Codex ApplyPatch DSL beginning with *** Begin Patch and ending with *** End Patch"
            }),
            Some(ToolFreeformGrammar {
                syntax: "lark".to_string(),
                definition: CODEX_DSL_LARK_GRAMMAR.to_string(),
            }),
        ),
        ApplyPatchSurface::UnifiedDiffJson => (
            "Apply a unified diff patch across one or more files. Call the JSON/function tool with exactly {\"patch\":\"--- a/path\\n+++ b/path\\n@@ ...\"}; do not use the Codex *** Begin Patch DSL as the advertised format.".to_string(),
            tool_input_schema::<ApplyPatchArgs>()?,
            None,
        ),
    };
    Ok(BuiltinToolDefinition {
        family: ToolCapabilityFamily::LocalEnvironment,
        spec: crate::tool::ToolSpec {
            name: NAME.to_string(),
            description,
            input_schema,
            freeform_grammar,
        },
    })
}

pub(crate) async fn execute(
    runtime: &RuntimeHandle,
    _agent_id: &str,
    _trust: &TrustLevel,
    input: &Value,
) -> Result<crate::tool::ToolResult> {
    let surface = runtime.current_apply_patch_surface().await;
    let patch_input = extract_patch_input(input, surface)?;
    let execution = runtime
        .effective_execution(ExecutionScopeKind::AgentTurn)
        .await?;
    let outcome =
        apply_patch::apply_patch(execution.workspace.execution_root(), &patch_input, surface)
            .await?;
    let mut summary_text = if outcome.changed_files.is_empty() {
        "patched no files".to_string()
    } else {
        format!(
            "patched {}",
            outcome
                .changed_files
                .iter()
                .map(render_changed_file_summary)
                .collect::<Vec<_>>()
                .join(", ")
        )
    };
    if !outcome.diagnostics.is_empty() {
        summary_text.push_str(&render_diagnostics_summary(&outcome.diagnostics));
    }
    serialize_success(
        NAME,
        &ApplyPatchResult {
            changed_files: outcome.changed_files,
            changed_file_count: outcome.changed_paths.len(),
            changed_paths: outcome.changed_paths,
            ignored_metadata: outcome.ignored_metadata,
            diagnostics: outcome.diagnostics,
            summary_text: Some(summary_text),
        },
    )
}

pub(crate) fn render_for_model(result: &ToolResult) -> Result<String> {
    if matches!(result.envelope.status, ToolResultStatus::Error) {
        let error = result
            .tool_error()
            .ok_or_else(|| anyhow!("ApplyPatch error result missing tool error"))?;
        return Ok(render_apply_patch_error_for_model(error));
    }

    let value = result
        .envelope
        .result
        .clone()
        .ok_or_else(|| anyhow!("ApplyPatch result missing payload"))?;
    let result: ApplyPatchResult = serde_json::from_value(value)?;

    let mut lines = if result.diagnostics.is_empty() {
        vec!["Success. Updated the following files:".to_string()]
    } else {
        vec!["Success with diagnostics. Updated the following files:".to_string()]
    };
    if result.changed_files.is_empty() {
        lines.push("(no file changes recorded)".to_string());
    } else {
        lines.extend(result.changed_files.iter().map(render_changed_file_receipt));
    }
    if !result.diagnostics.is_empty() {
        lines.push(String::new());
        lines.push("Diagnostics:".to_string());
        lines.extend(
            result
                .diagnostics
                .iter()
                .take(MODEL_DIAGNOSTIC_LIMIT)
                .map(render_diagnostic_for_model),
        );
        if result.diagnostics.len() > MODEL_DIAGNOSTIC_LIMIT {
            lines.push(format!(
                "- omitted {} additional diagnostics",
                result.diagnostics.len() - MODEL_DIAGNOSTIC_LIMIT
            ));
        }
        lines.push(
            "Inspect the affected target region before applying another patch to the same file."
                .to_string(),
        );
    }
    Ok(lines.join("\n"))
}

fn render_apply_patch_error_for_model(error: &ToolError) -> String {
    let mut lines = vec![
        "ApplyPatch failed".to_string(),
        format!("- kind: {}", error.kind),
        format!(
            "- message: {}",
            sanitize_model_visible_error_text(&error.message)
        ),
    ];
    if let Some(recovery_hint) = error.recovery_hint.as_deref() {
        lines.push(format!(
            "- recovery_hint: {}",
            sanitize_model_visible_error_text(recovery_hint)
        ));
    }
    if error.details.is_some() {
        lines.push(
            "- details: omitted from model-visible receipt; inspect audit/tool records if exact parser details are needed"
                .to_string(),
        );
    }
    if error.retryable {
        lines.push("- retryable: true".to_string());
    }
    if error.kind != "truncated_mutation_tool_call" {
        lines.push("Use the ApplyPatch format advertised for this turn, keep the patch smaller when possible, and inspect the target file before retrying.".to_string());
    }
    format!("{}\n", lines.join("\n"))
}

fn sanitize_model_visible_error_text(text: &str) -> String {
    let mut sanitized = String::new();
    let mut token_len = 0usize;
    let mut omitting_token = false;
    for ch in text.trim().chars() {
        if ch.is_whitespace() {
            token_len = 0;
            omitting_token = false;
            sanitized.push(ch);
            continue;
        }

        token_len += 1;
        if token_len <= MODEL_ERROR_TOKEN_LIMIT {
            sanitized.push(ch);
        } else if !omitting_token {
            sanitized.push_str("[long token omitted]");
            omitting_token = true;
        }
    }
    truncate_text(&sanitized, MODEL_ERROR_TEXT_LIMIT)
}

fn extract_patch_input(input: &Value, surface: ApplyPatchSurface) -> Result<String> {
    match input {
        Value::String(patch) => validate_non_empty(
            patch.clone(),
            NAME,
            match surface {
                ApplyPatchSurface::CodexDslFreeform => "input",
                ApplyPatchSurface::UnifiedDiffJson => "patch",
            },
        ),
        Value::Object(map) if map.contains_key("input") && !map.contains_key("patch") => {
            if matches!(surface, ApplyPatchSurface::CodexDslFreeform) {
                return map
                    .get("input")
                    .and_then(Value::as_str)
                    .map(|value| validate_non_empty(value.to_string(), NAME, "input"))
                    .transpose()?
                    .ok_or_else(|| {
                        invalid_tool_input(
                            NAME,
                            "ApplyPatch input must be a string",
                            serde_json::json!({"field": "input"}),
                            "Submit raw *** Begin Patch / *** End Patch text directly.",
                        )
                    });
            }
            Err(invalid_tool_input(
                NAME,
                "ApplyPatch expects `patch`, not `input`",
                serde_json::json!({
                    "field": "input",
                    "expected_field": "patch",
                }),
                "ApplyPatch expects exactly {\"patch\": \"--- a/path\\n+++ b/path\\n@@ -1,1 +1,1 @@\\n-old\\n+new\\n\"}. Do not use \"input\".",
            ))
        }
        _ => {
            let args: ApplyPatchArgs = parse_tool_args(NAME, input)?;
            validate_non_empty(args.patch, NAME, "patch")
        }
    }
}

fn render_changed_file_receipt(file: &crate::types::ApplyPatchChangedFile) -> String {
    match file.action {
        ApplyPatchAction::Move => format!(
            "{} {} -> {}",
            file.action.marker(),
            file.from_path.as_deref().unwrap_or("?"),
            file.path
        ),
        _ => format!("{} {}", file.action.marker(), file.path),
    }
}

fn render_diagnostic_for_model(diagnostic: &ApplyPatchDiagnostic) -> String {
    let path = if diagnostic.path.trim().is_empty() {
        "unknown path".to_string()
    } else {
        sanitize_model_visible_error_text(&diagnostic.path)
    };
    format!(
        "- {} on {}: {}",
        diagnostic.kind,
        path,
        sanitize_model_visible_error_text(&diagnostic.message)
    )
}

fn render_diagnostics_summary(diagnostics: &[ApplyPatchDiagnostic]) -> String {
    let mut parts = diagnostics
        .iter()
        .take(SUMMARY_DIAGNOSTIC_LIMIT)
        .map(render_diagnostic_summary)
        .collect::<Vec<_>>();
    if diagnostics.len() > SUMMARY_DIAGNOSTIC_LIMIT {
        parts.push(format!(
            "+{} more",
            diagnostics.len() - SUMMARY_DIAGNOSTIC_LIMIT
        ));
    }
    format!(" (diagnostics: {})", parts.join(", "))
}

fn render_diagnostic_summary(diagnostic: &ApplyPatchDiagnostic) -> String {
    let path = if diagnostic.path.trim().is_empty() {
        "unknown path".to_string()
    } else {
        diagnostic.path.clone()
    };
    sanitize_model_visible_error_text(&format!("{} on {}", diagnostic.kind, path))
}

fn render_changed_file_summary(file: &crate::types::ApplyPatchChangedFile) -> String {
    match file.action {
        ApplyPatchAction::Move => format!(
            "{}:{}->{}",
            file.action.marker(),
            file.from_path.as_deref().unwrap_or("?"),
            file.path
        ),
        _ => format!("{}:{}", file.action.marker(), file.path),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tool::ToolError;
    use serde_json::json;

    #[test]
    fn apply_patch_renders_text_receipt() {
        let result = serialize_success(
            NAME,
            &ApplyPatchResult {
                changed_files: vec![
                    crate::types::ApplyPatchChangedFile {
                        action: ApplyPatchAction::Modify,
                        path: "src/lib.rs".into(),
                        from_path: None,
                    },
                    crate::types::ApplyPatchChangedFile {
                        action: ApplyPatchAction::Add,
                        path: "README.md".into(),
                        from_path: None,
                    },
                ],
                changed_file_count: 2,
                changed_paths: vec!["src/lib.rs".into(), "README.md".into()],
                ignored_metadata: Vec::new(),
                diagnostics: Vec::new(),
                summary_text: Some("patched src/lib.rs, README.md".into()),
            },
        )
        .unwrap();

        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("Success. Updated the following files:"));
        assert!(rendered.contains("M src/lib.rs"));
        assert!(rendered.contains("A README.md"));
    }

    #[test]
    fn apply_patch_renders_diagnostics_in_model_receipt() {
        let result = serialize_success(
            NAME,
            &ApplyPatchResult {
                changed_files: vec![crate::types::ApplyPatchChangedFile {
                    action: ApplyPatchAction::Modify,
                    path: "src/lib.rs".into(),
                    from_path: None,
                }],
                changed_file_count: 1,
                changed_paths: vec!["src/lib.rs".into()],
                ignored_metadata: Vec::new(),
                diagnostics: vec![ApplyPatchDiagnostic {
                    path: "src/lib.rs".into(),
                    kind: "hunk_count_mismatch".into(),
                    message: "hunk header declared -1,1 +1,1 but body counted -1,20 +1,30".into(),
                }],
                summary_text: Some("patched M:src/lib.rs".into()),
            },
        )
        .unwrap();

        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("Success with diagnostics"));
        assert!(rendered.contains("M src/lib.rs"));
        assert!(rendered.contains("Diagnostics:"));
        assert!(rendered.contains("hunk_count_mismatch"));
        assert!(rendered.contains("Inspect the affected target region"));
    }

    #[test]
    fn apply_patch_renders_bounded_diagnostics_in_model_receipt() {
        let diagnostics = (0..(MODEL_DIAGNOSTIC_LIMIT + 2))
            .map(|idx| ApplyPatchDiagnostic {
                path: format!("src/lib{idx}.rs"),
                kind: "hunk_count_mismatch".into(),
                message: format!("diagnostic {idx}"),
            })
            .collect::<Vec<_>>();
        let result = serialize_success(
            NAME,
            &ApplyPatchResult {
                changed_files: vec![crate::types::ApplyPatchChangedFile {
                    action: ApplyPatchAction::Modify,
                    path: "src/lib.rs".into(),
                    from_path: None,
                }],
                changed_file_count: 1,
                changed_paths: vec!["src/lib.rs".into()],
                ignored_metadata: Vec::new(),
                diagnostics,
                summary_text: Some("patched M:src/lib.rs".into()),
            },
        )
        .unwrap();

        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("diagnostic 7"));
        assert!(!rendered.contains("diagnostic 8"));
        assert!(rendered.contains("- omitted 2 additional diagnostics"));
    }

    #[test]
    fn apply_patch_diagnostics_summary_is_bounded() {
        let diagnostics = (0..(SUMMARY_DIAGNOSTIC_LIMIT + 2))
            .map(|idx| ApplyPatchDiagnostic {
                path: format!("src/lib{idx}.rs"),
                kind: "hunk_count_mismatch".into(),
                message: format!("diagnostic {idx}"),
            })
            .collect::<Vec<_>>();

        let summary = render_diagnostics_summary(&diagnostics);
        assert!(summary.contains("diagnostics: hunk_count_mismatch on src/lib0.rs"));
        assert!(summary.contains("+2 more"));
        assert!(!summary.contains("src/lib3.rs"));
    }

    #[test]
    fn apply_patch_error_renders_failure_receipt() {
        let result = ToolResult::error(NAME, ToolError::new("patch_failed", "patch exploded"));
        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("ApplyPatch failed"));
        assert!(rendered.contains("patch exploded"));
    }

    #[test]
    fn apply_patch_truncated_mutation_error_does_not_force_file_inspection() {
        let result = ToolResult::error(
            NAME,
            ToolError::new(
                "truncated_mutation_tool_call",
                "ApplyPatch was not executed because the provider stopped with max_tokens",
            )
            .with_recovery_hint("Inspect only the necessary context")
            .with_retryable(true),
        );
        let rendered = render_for_model(&result).unwrap();

        assert!(rendered.contains("ApplyPatch failed"));
        assert!(rendered.contains("truncated_mutation_tool_call"));
        assert!(rendered.contains("Inspect only the necessary context"));
        assert!(rendered.contains("retryable: true"));
        assert!(!rendered.contains("inspect the target file before retrying"));
    }

    #[test]
    fn apply_patch_error_omits_large_details_from_model_receipt() {
        let long_path = format!("src/{}.rs", "nested".repeat(600));
        let invalid_fragment = format!("{} code to=functions.ApplyPatch", "***".repeat(2000));
        let result = ToolResult::error(
            NAME,
            ToolError::new(
                "invalid_patch_syntax",
                format!("invalid patch near {}", "x".repeat(400)),
            )
            .with_details(json!({
                "path": long_path,
                "fragment": invalid_fragment,
            }))
            .with_recovery_hint(format!("inspect {}", "target".repeat(200))),
        );

        let rendered = render_for_model(&result).unwrap();
        assert!(rendered.contains("ApplyPatch failed"));
        assert!(rendered.contains("details: omitted"));
        assert!(rendered.contains("[long token omitted]"));
        assert!(!rendered.contains("code to=functions.ApplyPatch"));
        assert!(!rendered.contains(&"nested".repeat(60)));
        assert!(rendered.len() < 1_200);
    }

    #[test]
    fn apply_patch_json_input_rejects_legacy_input_field() {
        let error = extract_patch_input(
            &serde_json::json!({
                "input": "--- a/old.txt\n+++ b/old.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"
            }),
            ApplyPatchSurface::UnifiedDiffJson,
        )
        .unwrap_err();
        let tool_error = crate::tool::ToolError::from_anyhow(&error);
        assert_eq!(tool_error.kind, "invalid_tool_input");
        assert!(tool_error
            .recovery_hint
            .as_deref()
            .unwrap_or_default()
            .contains("Do not use \"input\""));
    }

    #[test]
    fn apply_patch_codex_freeform_empty_string_uses_input_label() {
        let error = extract_patch_input(
            &serde_json::json!("   "),
            ApplyPatchSurface::CodexDslFreeform,
        )
        .unwrap_err();
        let tool_error = crate::tool::ToolError::from_anyhow(&error);
        assert_eq!(tool_error.kind, "invalid_tool_input");
        assert_eq!(tool_error.details.as_ref().unwrap()["field"], "input");
        assert!(!tool_error.message.contains("`patch`"));
    }

    #[test]
    fn apply_patch_function_fallback_uses_patch_field() {
        let spec = definition().unwrap().spec;
        assert!(spec.input_schema["properties"]["patch"].is_object());
        assert!(spec.input_schema["properties"]["input"].is_null());
        assert!(spec.freeform_grammar.is_none());
    }

    #[test]
    fn apply_patch_codex_surface_uses_freeform_dsl_grammar() {
        let spec = definition_for_surface(ApplyPatchSurface::CodexDslFreeform)
            .unwrap()
            .spec;
        assert!(spec.input_schema["type"] == "string");
        let grammar = spec.freeform_grammar.expect("codex grammar");
        assert!(grammar.definition.contains("*** Begin Patch"));
        assert!(!spec.description.contains("unified diff"));
    }

    #[test]
    fn apply_patch_result_deserializes_without_changed_files() {
        let parsed: ApplyPatchResult = serde_json::from_value(json!({
            "changed_paths": ["src/lib.rs"],
            "changed_file_count": 1
        }))
        .unwrap();
        assert!(parsed.changed_files.is_empty());
        assert_eq!(parsed.changed_paths, vec!["src/lib.rs"]);
    }

    #[test]
    fn apply_patch_codex_freeform_grammar_requires_codex_hunks() {
        let grammar = definition_for_surface(ApplyPatchSurface::CodexDslFreeform)
            .unwrap()
            .spec
            .freeform_grammar
            .expect("apply patch should expose freeform grammar")
            .definition;
        assert!(grammar.contains("begin_patch: \"*** Begin Patch\" LF"));
        assert!(grammar.contains("update_hunk: \"*** Update File: \""));
        assert!(grammar.contains("change_move: \"*** Move to: \""));
        assert!(!grammar.contains("old_file: \"--- \" file_path LF"));
    }
}
