use crate::schema::json_schema_for;
use anyhow::Result;
use assistant_tool::{ActionLog, Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{fmt::Write, sync::Arc};
use ui::IconName;

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ProjectUpdatesToolInput {}

pub struct ProjectNotificationsTool;

impl Tool for ProjectNotificationsTool {
    fn name(&self) -> String {
        "project_notifications".to_string()
    }

    fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
        false
    }
    fn may_perform_edits(&self) -> bool {
        false
    }
    fn description(&self) -> String {
        include_str!("./project_notifications_tool/description.md").to_string()
    }

    fn icon(&self) -> IconName {
        IconName::ToolNotification
    }

    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
        json_schema_for::<ProjectUpdatesToolInput>(format)
    }

    fn ui_text(&self, _input: &serde_json::Value) -> String {
        "Check project notifications".into()
    }

    fn run(
        self: Arc<Self>,
        _input: serde_json::Value,
        _request: Arc<LanguageModelRequest>,
        _project: Entity<Project>,
        action_log: Entity<ActionLog>,
        _model: Arc<dyn LanguageModel>,
        _window: Option<AnyWindowHandle>,
        cx: &mut App,
    ) -> ToolResult {
        let Some(user_edits_diff) =
            action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx))
        else {
            return result("No new notifications");
        };

        // NOTE: Changes to this prompt require a symmetric update in the LLM Worker
        const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt");
        const MAX_BYTES: usize = 8000;
        let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES);
        result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n"))
    }
}

fn result(response: &str) -> ToolResult {
    Task::ready(Ok(response.to_string().into())).into()
}

/// Make sure that the patch fits into the size limit (in bytes).
/// Compress the patch by omitting some parts if needed.
/// Unified diff format is assumed.
fn fit_patch_to_size(patch: &str, max_size: usize) -> String {
    if patch.len() <= max_size {
        return patch.to_string();
    }

    // Compression level 1: remove context lines in diff bodies, but
    // leave the counts and positions of inserted/deleted lines
    let mut current_size = patch.len();
    let mut file_patches = split_patch(&patch);
    file_patches.sort_by_key(|patch| patch.len());
    let compressed_patches = file_patches
        .iter()
        .rev()
        .map(|patch| {
            if current_size > max_size {
                let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string());
                current_size -= patch.len() - compressed.len();
                compressed
            } else {
                patch.to_string()
            }
        })
        .collect::<Vec<_>>();

    if current_size <= max_size {
        return compressed_patches.join("\n\n");
    }

    // Compression level 2: list paths of the changed files only
    let filenames = file_patches
        .iter()
        .map(|patch| {
            let patch = diffy::Patch::from_str(patch).unwrap();
            let path = patch
                .modified()
                .and_then(|path| path.strip_prefix("b/"))
                .unwrap_or_default();
            format!("- {path}\n")
        })
        .collect::<Vec<_>>();

    filenames.join("")
}

/// Split a potentially multi-file patch into multiple single-file patches
fn split_patch(patch: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut current_patch = String::new();

    for line in patch.lines() {
        if line.starts_with("---") && !current_patch.is_empty() {
            result.push(current_patch.trim_end_matches('\n').into());
            current_patch = String::new();
        }
        current_patch.push_str(line);
        current_patch.push('\n');
    }

    if !current_patch.is_empty() {
        result.push(current_patch.trim_end_matches('\n').into());
    }

    result
}

fn compress_patch(patch: &str) -> anyhow::Result<String> {
    let patch = diffy::Patch::from_str(patch)?;
    let mut out = String::new();

    writeln!(out, "--- {}", patch.original().unwrap_or("a"))?;
    writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?;

    for hunk in patch.hunks() {
        writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?;
        writeln!(out, "[...skipped...]")?;
    }

    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use assistant_tool::ToolResultContent;
    use gpui::{AppContext, TestAppContext};
    use indoc::indoc;
    use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider};
    use project::{FakeFs, Project};
    use serde_json::json;
    use settings::SettingsStore;
    use std::sync::Arc;
    use util::path;

    #[gpui::test]
    async fn test_stale_buffer_notification(cx: &mut TestAppContext) {
        init_test(cx);

        let fs = FakeFs::new(cx.executor());
        fs.insert_tree(
            path!("/test"),
            json!({"code.rs": "fn main() {\n    println!(\"Hello, world!\");\n}"}),
        )
        .await;

        let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
        let action_log = cx.new(|_| ActionLog::new(project.clone()));

        let buffer_path = project
            .read_with(cx, |project, cx| {
                project.find_project_path("test/code.rs", cx)
            })
            .unwrap();

        let buffer = project
            .update(cx, |project, cx| {
                project.open_buffer(buffer_path.clone(), cx)
            })
            .await
            .unwrap();

        // Start tracking the buffer
        action_log.update(cx, |log, cx| {
            log.buffer_read(buffer.clone(), cx);
        });
        cx.run_until_parked();

        // Run the tool before any changes
        let tool = Arc::new(ProjectNotificationsTool);
        let provider = Arc::new(FakeLanguageModelProvider::default());
        let model: Arc<dyn LanguageModel> = Arc::new(provider.test_model());
        let request = Arc::new(LanguageModelRequest::default());
        let tool_input = json!({});

        let result = cx.update(|cx| {
            tool.clone().run(
                tool_input.clone(),
                request.clone(),
                project.clone(),
                action_log.clone(),
                model.clone(),
                None,
                cx,
            )
        });
        cx.run_until_parked();

        let response = result.output.await.unwrap();
        let response_text = match &response.content {
            ToolResultContent::Text(text) => text.clone(),
            _ => panic!("Expected text response"),
        };
        assert_eq!(
            response_text.as_str(),
            "No new notifications",
            "Tool should return 'No new notifications' when no stale buffers"
        );

        // Modify the buffer (makes it stale)
        buffer.update(cx, |buffer, cx| {
            buffer.edit([(1..1, "\nChange!\n")], None, cx);
        });
        cx.run_until_parked();

        // Run the tool again
        let result = cx.update(|cx| {
            tool.clone().run(
                tool_input.clone(),
                request.clone(),
                project.clone(),
                action_log.clone(),
                model.clone(),
                None,
                cx,
            )
        });
        cx.run_until_parked();

        // This time the buffer is stale, so the tool should return a notification
        let response = result.output.await.unwrap();
        let response_text = match &response.content {
            ToolResultContent::Text(text) => text.clone(),
            _ => panic!("Expected text response"),
        };

        assert!(
            response_text.contains("These files have changed"),
            "Tool should return the stale buffer notification"
        );
        assert!(
            response_text.contains("test/code.rs"),
            "Tool should return the stale buffer notification"
        );

        // Run the tool once more without any changes - should get no new notifications
        let result = cx.update(|cx| {
            tool.run(
                tool_input.clone(),
                request.clone(),
                project.clone(),
                action_log,
                model.clone(),
                None,
                cx,
            )
        });
        cx.run_until_parked();

        let response = result.output.await.unwrap();
        let response_text = match &response.content {
            ToolResultContent::Text(text) => text.clone(),
            _ => panic!("Expected text response"),
        };

        assert_eq!(
            response_text.as_str(),
            "No new notifications",
            "Tool should return 'No new notifications' when running again without changes"
        );
    }

    #[test]
    fn test_patch_compression() {
        // Given a patch that doesn't fit into the size budget
        let patch = indoc! {"
       --- a/dir/test.txt
       +++ b/dir/test.txt
       @@ -1,3 +1,3 @@
        line 1
       -line 2
       +CHANGED
        line 3
       @@ -10,2 +10,2 @@
        line 10
       -line 11
       +line eleven


       --- a/dir/another.txt
       +++ b/dir/another.txt
       @@ -100,1 +1,1 @@
       -before
       +after
       "};

        // When the size deficit can be compensated by dropping the body,
        // then the body should be trimmed for larger files first
        let limit = patch.len() - 10;
        let compressed = fit_patch_to_size(patch, limit);
        let expected = indoc! {"
       --- a/dir/test.txt
       +++ b/dir/test.txt
       @@ -1,3 +1,3 @@
       [...skipped...]
       @@ -10,2 +10,2 @@
       [...skipped...]


       --- a/dir/another.txt
       +++ b/dir/another.txt
       @@ -100,1 +1,1 @@
       -before
       +after"};
        assert_eq!(compressed, expected);

        // When the size deficit is too large, then only file paths
        // should be returned
        let limit = 10;
        let compressed = fit_patch_to_size(patch, limit);
        let expected = indoc! {"
       - dir/another.txt
       - dir/test.txt
       "};
        assert_eq!(compressed, expected);
    }

    fn init_test(cx: &mut TestAppContext) {
        cx.update(|cx| {
            let settings_store = SettingsStore::test(cx);
            cx.set_global(settings_store);
            language::init(cx);
            Project::init_settings(cx);
            assistant_tool::init(cx);
        });
    }
}
