//! LSP client — JSON-RPC over stdio backed by `async-lsp`.
//!
//! Uses a **hybrid actor model**: the editor sends [`LspCommand`]s through an
//! unbounded channel to a lightweight `lsp_actor_loop` task.  That task gates
//! pre-initialisation commands and forwards them to an `async-lsp`
//! [`ServerSocket`].  A separate `MainLoop` task drives the full JSON-RPC
//! framing and routes incoming server→client notifications (e.g.
//! `publishDiagnostics`) to the [`IssueRegistry`] via `op_tx`.
//!
//! Architecture:
//!
//! ```text
//!  Editor thread
//!      │
//!      ▼
//!  LspClient.cmd_tx ──► lsp_actor_loop (gates pre-init, sequential)
//!                               │
//!                               ├── ServerSocket.notify(...)  ──► async-lsp MainLoop ──► server stdin
//!                               └── ServerSocket.request(...) ──► async-lsp MainLoop ──► server stdin
//!                                                                        │
//!                                async-lsp Router ◄── server stdout ◄───┘
//!                                      │
//!                                 publishDiagnostics ──► op_tx ──► editor event loop
//! ```

use std::collections::{VecDeque, HashMap};
use std::ops::ControlFlow;
use std::path::Path;

use async_lsp::lsp_types::{
    ClientCapabilities, CompletionClientCapabilities, CompletionItemCapability, CompletionParams,
    CompletionResponse, CompletionContext, CompletionTriggerKind,
    DidChangeTextDocumentParams, DidOpenTextDocumentParams, GotoCapability,
    GotoDefinitionParams, GotoDefinitionResponse, HoverClientCapabilities, HoverContents,
    HoverParams, HoverProviderCapability, InitializeParams, InitializedParams,
    MarkedString, PublishDiagnosticsClientCapabilities, RenameParams, TextDocumentClientCapabilities,
    TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem,
    TextDocumentPositionParams, TextEdit, Url, VersionedTextDocumentIdentifier,
    notification, request, Range, Position,
    TextDocumentSyncKind, TextDocumentSyncCapability,
    WorkspaceEdit,
};
use async_lsp::router::Router;
use async_lsp::{MainLoop, ServerSocket};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};

use crate::operation::{LspCompletionItem, LspFileEdit, LspLocation, LspOp, LspRenameOp, LspTextEdit, Operation};

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

pub(crate) struct LspClient {
    cmd_tx: mpsc::UnboundedSender<LspCommand>,
    /// Keep child alive so it is killed when LspClient is dropped.
    _child: Child,
    /// JoinHandle for the async-lsp MainLoop task (so it can be aborted on drop).
    mainloop_handle: Option<tokio::task::JoinHandle<()>>,
    /// JoinHandle for the actor loop task.
    actor_handle: Option<tokio::task::JoinHandle<()>>,
}

impl Drop for LspClient {
    fn drop(&mut self) {
        // Abort both long-running tasks to avoid races during shutdown where
        // the actor exits, drops its ServerSocket, and causes the MainLoop's
        // receiver to end unexpectedly (which async-lsp currently treats as
        // a logic error and panics). Aborting prevents that race.
        if let Some(handle) = self.actor_handle.take() {
            handle.abort();
        }
        if let Some(handle) = self.mainloop_handle.take() {
            handle.abort();
        }
    }
}

impl LspClient {
    pub(crate) async fn spawn(
        cmd: &str,
        args: &[String],
        op_tx: mpsc::UnboundedSender<Vec<Operation>>,
        server_name: impl Into<String>,
    ) -> anyhow::Result<Self> {
        let server_name = server_name.into();
        let mut child = Command::new(cmd)
            .args(args)
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .kill_on_drop(true)
            .spawn()?;

        let stdin = child.stdin.take().expect("stdin piped");
        let stdout = child.stdout.take().expect("stdout piped");
        let stderr = child.stderr.take().expect("stderr piped");

        // Forward stderr lines from the LSP server into the application logs.
        tokio::spawn(async move {
            let mut reader = BufReader::new(stderr);
            let mut line = String::new();
            loop {
                line.clear();
                match reader.read_line(&mut line).await {
                    Ok(0) => break,
                    Ok(_) => log::error!("lsp stderr: {}", line.trim_end()),
                    Err(e) => {
                        log::error!("lsp stderr read error: {e}");
                        break;
                    }
                }
            }
        });

        // Build the async-lsp MainLoop with a Router that handles server→client
        // notifications (publishDiagnostics, showMessage, …).
        let (mainloop, server) = MainLoop::new_client(|_server_socket| {
            let mut router = Router::new(OoClientState {
                op_tx: op_tx.clone(),
                server_name: server_name.clone(),
            });
            router
                .notification::<notification::PublishDiagnostics>(|st, params| {
                    handle_publish_diagnostics(&st.op_tx, &st.server_name, params);
                    ControlFlow::Continue(())
                })
                .notification::<notification::ShowMessage>(|_st, _| ControlFlow::Continue(()))
                .notification::<notification::LogMessage>(|_st, _| ControlFlow::Continue(()))
                .notification::<notification::Progress>(|_st, _| ControlFlow::Continue(()))
                .unhandled_notification(|_st, _| ControlFlow::Continue(()));
            router
        });

        // Spawn the mainloop: bridges tokio ChildStdout/ChildStdin to futures I/O.
        let stdin_compat = stdin.compat_write();
        let stdout_compat = stdout.compat();
        let mainloop_handle = tokio::spawn(async move {
            if let Err(e) = mainloop.run_buffered(stdout_compat, stdin_compat).await {
                log::debug!("lsp mainloop exited: {e}");
            }
        });

        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<LspCommand>();
        let actor_handle = tokio::spawn(lsp_actor_loop(LspActor {
            server,
            op_tx,
            cmd_rx,
            initialized: false,
            queued_cmds: VecDeque::new(),
            use_incremental: false,
            supports_hover: false,
            supports_rename: false,
            supports_prepare_rename: false,
            doc_states: HashMap::new(),
            trigger_chars: HashMap::new(),
        }));

        Ok(Self {
            cmd_tx,
            _child: child,
            mainloop_handle: Some(mainloop_handle),
            actor_handle: Some(actor_handle),
        })
    }

    // All public methods take `&self` — no mutable state on the client side.

    pub(crate) fn initialize(&self, root_uri: &str) {
        let _ = self.cmd_tx.send(LspCommand::Initialize {
            root_uri: root_uri.into(),
        });
    }

    /// Attempt to shutdown the LSP server cleanly: request `shutdown` and notify `exit`.
    ///
    /// This is best-effort and intended to be called during application shutdown so
    /// the async-lsp main loop exits without panicking when channels are dropped.
    #[allow(dead_code)]
    pub(crate) fn shutdown(&self) {
        // Send a best-effort shutdown command to the actor loop. If the actor is already
        // gone this will simply fail silently.
        let _ = self.cmd_tx.send(LspCommand::Shutdown);
    }

    pub(crate) fn did_open(&self, uri: &str, lang_id: &str, text: &str) {
        let _ = self.cmd_tx.send(LspCommand::DidOpen {
            uri: uri.into(),
            lang_id: lang_id.into(),
            text: text.into(),
        });
    }

    pub(crate) fn did_change(&self, uri: &str, version: i32, text: &str) {
        let _ = self.cmd_tx.send(LspCommand::DidChange {
            uri: uri.into(),
            version,
            text: text.into(),
        });
    }

    pub(crate) fn request_completion(&self, uri: &str, row: u32, col: u32, trigger_char: Option<String>) {
        let _ = self.cmd_tx.send(LspCommand::RequestCompletion {
            uri: uri.into(),
            row,
            col,
            trigger_char,
        });
    }

    pub(crate) fn request_hover(&self, uri: &str, row: u32, col: u32) {
        let _ = self.cmd_tx.send(LspCommand::RequestHover {
            uri: uri.into(),
            row,
            col,
        });
    }

    #[allow(dead_code)]
    pub(crate) fn shutdown_notify(&self) {
        let _ = self.cmd_tx.send(LspCommand::Shutdown);
    }

    pub(crate) fn request_definition(&self, uri: &str, row: u32, col: u32) {
        let _ = self.cmd_tx.send(LspCommand::RequestDefinition {
            uri: uri.into(),
            row,
            col,
        });
    }

    pub(crate) fn request_prepare_rename(&self, uri: &str, row: u32, col: u32, fallback_name: String) {
        let _ = self.cmd_tx.send(LspCommand::RequestPrepareRename {
            uri: uri.into(),
            row,
            col,
            fallback_name,
        });
    }

    pub(crate) fn request_rename(&self, uri: &str, row: u32, col: u32, new_name: String) {
        let _ = self.cmd_tx.send(LspCommand::RequestRename {
            uri: uri.into(),
            row,
            col,
            new_name,
        });
    }

}

// ---------------------------------------------------------------------------
// URI / lang helpers (pub for use in app.rs and tests)
// ---------------------------------------------------------------------------

pub(crate) fn path_to_uri(path: &Path) -> String {
    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    let s = canonical.to_string_lossy();
    if cfg!(windows) {
        // canonicalize() on Windows returns extended-length paths: \\?\C:\foo\bar
        // Strip the \\?\ prefix so the URI becomes file:///C:/foo/bar, not file://///?/C:/foo/bar
        let stripped = s.strip_prefix(r"\\?\").unwrap_or(&s);
        format!("file:///{}", stripped.replace('\\', "/"))
    } else {
        format!("file://{}", s)
    }
}

pub(crate) fn lang_id_for_path(path: &Path) -> &'static str {
    crate::language::detect_language(path)
        .and_then(|id| match id.0 {
            std::borrow::Cow::Borrowed(s) => Some(s),
            std::borrow::Cow::Owned(_) => None,
        })
        .unwrap_or("plaintext")
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

fn uri_to_path(uri: &str) -> std::path::PathBuf {
    let path = uri.strip_prefix("file://").unwrap_or(uri);
    let path = if cfg!(windows) {
        path.strip_prefix('/').unwrap_or(path).replace('/', "\\")
    } else {
        path.to_owned()
    };
    std::path::PathBuf::from(path)
}

// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------

enum LspCommand {
    Initialize { root_uri: String },
    DidOpen { uri: String, lang_id: String, text: String },
    DidChange { uri: String, version: i32, text: String },
    RequestCompletion { uri: String, row: u32, col: u32, trigger_char: Option<String> },
    RequestHover { uri: String, row: u32, col: u32 },
    RequestDefinition { uri: String, row: u32, col: u32 },
    RequestPrepareRename { uri: String, row: u32, col: u32, fallback_name: String },
    RequestRename { uri: String, row: u32, col: u32, new_name: String },
    /// Graceful shutdown request: actor should perform shutdown and notify exit.
    Shutdown,
}

/// State passed to every Router notification handler.
struct OoClientState {
    op_tx: mpsc::UnboundedSender<Vec<Operation>>,
    /// Language / server identifier forwarded to every diagnostic NewIssue as `source`.
    server_name: String,
}

/// Simplified actor: no pending HashMap, no stdin handle.
/// async-lsp handles JSON framing and request/response matching.
struct DocumentState {
    version: i32,
    text: String,
}

struct LspActor {
    server: ServerSocket,
    op_tx: mpsc::UnboundedSender<Vec<Operation>>,
    cmd_rx: mpsc::UnboundedReceiver<LspCommand>,
    initialized: bool,
    queued_cmds: VecDeque<LspCommand>,
    /// Whether the connected server supports incremental text sync.
    use_incremental: bool,
    /// Whether the connected server advertises hover support (`hoverProvider`).
    supports_hover: bool,
    /// Whether the connected server advertises rename support (`renameProvider`).
    supports_rename: bool,
    /// Whether the connected server supports `textDocument/prepareRename`.
    supports_prepare_rename: bool,
    /// Shadow copies of open documents for computing incremental edits.
    doc_states: HashMap<String, DocumentState>,
    /// Trigger characters advertised by the server (per language).
    trigger_chars: HashMap<String, Vec<String>>,
}

// ---------------------------------------------------------------------------
// Actor loop
// ---------------------------------------------------------------------------

async fn lsp_actor_loop(mut actor: LspActor) {
    log::debug!("lsp actor: started");
    loop {
        // Drain any commands that were queued before initialization.
        while actor.initialized {
            let Some(cmd) = actor.queued_cmds.pop_front() else { break };
            execute_command(&mut actor, cmd).await;
        }

        let Some(cmd) = actor.cmd_rx.recv().await else {
            log::debug!("lsp actor: cmd_rx closed, exiting");
            break;
        };
        execute_command(&mut actor, cmd).await;
    }
}

async fn execute_command(actor: &mut LspActor, cmd: LspCommand) {
    // Gate commands that arrive before initialization.
    if !actor.initialized && !matches!(cmd, LspCommand::Initialize { .. }) {
        log::debug!("lsp actor: queuing command until initialized");
        actor.queued_cmds.push_back(cmd);
        return;
    }

    match cmd {
        LspCommand::Initialize { root_uri } => {
            log::debug!("lsp actor: initialize root={root_uri}");
            let params = {
                #[allow(deprecated)]
                InitializeParams {
                    root_uri: Url::parse(&root_uri).ok(),
                    capabilities: build_client_capabilities(),
                    ..Default::default()
                }
            };
            match actor.server.request::<request::Initialize>(params).await {
                Ok(res) => {
                    // Detect whether server supports incremental text sync
                    let supports_incremental = match res.capabilities.text_document_sync {
                        Some(tds) => match tds {
                            TextDocumentSyncCapability::Kind(kind) => {
                                matches!(kind, TextDocumentSyncKind::INCREMENTAL)
                            }
                            TextDocumentSyncCapability::Options(opts) => {
                                matches!(opts.change, Some(TextDocumentSyncKind::INCREMENTAL))
                            }
                        },
                        None => false,
                    };
                    actor.use_incremental = supports_incremental;

                    // Extract trigger characters from server capabilities
                    if let Some(completion_provider) = &res.capabilities.completion_provider
                        && let Some(triggers) = &completion_provider.trigger_characters {
                            let chars: Vec<String> = triggers.iter().map(|s| s.to_string()).collect();
                            if !chars.is_empty() {
                                log::debug!("lsp actor: server trigger characters: {:?}", chars);
                                // Store for default lang (can be extended to per-language)
                                actor.trigger_chars.insert("default".to_string(), chars);
                            }
                        }

                    // Check if server supports hover.
                    actor.supports_hover = matches!(
                        res.capabilities.hover_provider,
                        Some(HoverProviderCapability::Simple(true))
                            | Some(HoverProviderCapability::Options(_))
                    );
                    log::debug!("lsp actor: supports_hover={}", actor.supports_hover);

                    // Check if server supports rename / prepareRename.
                    match &res.capabilities.rename_provider {
                        Some(async_lsp::lsp_types::OneOf::Left(true)) => {
                            actor.supports_rename = true;
                            actor.supports_prepare_rename = false;
                        }
                        Some(async_lsp::lsp_types::OneOf::Right(opts)) => {
                            actor.supports_rename = true;
                            actor.supports_prepare_rename = opts.prepare_provider.unwrap_or(false);
                        }
                        _ => {
                            actor.supports_rename = false;
                            actor.supports_prepare_rename = false;
                        }
                    }
                    log::debug!("lsp actor: supports_rename={} supports_prepare_rename={}",
                        actor.supports_rename, actor.supports_prepare_rename);

                    let _ = actor.server.notify::<notification::Initialized>(InitializedParams {});
                    actor.initialized = true;

                    // Request trigger characters from server capabilities
                    // This will return an empty vec if the server doesn't advertise them
                    let default_chars: Vec<String> = Vec::new();
                    let chars = actor.trigger_chars.get("default").unwrap_or(&default_chars).clone();

                    let _ = actor.op_tx.send(vec![Operation::LspTriggerCharactersReceived {
                        lang_id: "default".to_string(),
                        trigger_chars: chars,
                    }]);

                    log::debug!("lsp actor: initialized; incremental={}", actor.use_incremental);
                }
                Err(e) => log::warn!("[lsp] initialize failed: {e}"),
            }
        }

        LspCommand::DidOpen { uri, lang_id, text } => {
            // Keep a shadow copy of the opened document for incremental diffs.
            let text_clone = text.clone();
            let lang_clone = lang_id.clone();
            let uri_parsed = Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap());
            let _ = actor.server.notify::<notification::DidOpenTextDocument>(
                DidOpenTextDocumentParams {
                    text_document: TextDocumentItem {
                        uri: uri_parsed,
                        language_id: lang_clone,
                        version: 0,
                        text: text_clone.clone(),
                    },
                },
            );
            actor.doc_states.insert(
                uri.clone(),
                DocumentState {
                    version: 0,
                    text: text_clone,
                },
            );
        }

        LspCommand::DidChange { uri, version, text } => {
            // Try to send an incremental change when supported and we have a shadow copy.
            let uri_parsed = Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap());
            if actor.use_incremental {
                if let Some(state) = actor.doc_states.get_mut(&uri) {
                    // If nothing changed, just update version and skip notifying.
                    if state.text == text {
                        state.version = version;
                    } else {
                        let (old_start, old_end, new_start, new_end) =
                            compute_change_range(&state.text, &text);
                        let (start_line, start_char) = byte_index_to_position(&state.text, old_start);
                        let (end_line, end_char) = byte_index_to_position(&state.text, old_end);
                        let range = Range {
                            start: Position { line: start_line, character: start_char },
                            end: Position { line: end_line, character: end_char },
                        };
                        let changed_text = &text[new_start..new_end];
                        let _ = actor.server.notify::<notification::DidChangeTextDocument>(
                            DidChangeTextDocumentParams {
                                text_document: VersionedTextDocumentIdentifier {
                                    uri: uri_parsed.clone(),
                                    version,
                                },
                                content_changes: vec![TextDocumentContentChangeEvent {
                                    range: Some(range),
                                    range_length: None,
                                    text: changed_text.to_string(),
                                }],
                            },
                        );
                        state.text = text;
                        state.version = version;
                    }
                } else {
                    // No shadow copy — fall back to full sync and create shadow.
                    let _ = actor.server.notify::<notification::DidChangeTextDocument>(
                        DidChangeTextDocumentParams {
                            text_document: VersionedTextDocumentIdentifier {
                                uri: uri_parsed.clone(),
                                version,
                            },
                            content_changes: vec![TextDocumentContentChangeEvent {
                                range: None,
                                range_length: None,
                                text: text.clone(),
                            }],
                        },
                    );
                    actor.doc_states.insert(
                        uri.clone(),
                        DocumentState { version, text },
                    );
                }
            } else {
                // Server does not support incremental sync — send full text.
                let _ = actor.server.notify::<notification::DidChangeTextDocument>(
                    DidChangeTextDocumentParams {
                        text_document: VersionedTextDocumentIdentifier {
                            uri: uri_parsed.clone(),
                            version,
                        },
                        content_changes: vec![TextDocumentContentChangeEvent {
                            range: None,
                            range_length: None,
                            text: text.clone(),
                        }],
                    },
                );
                if let Some(state) = actor.doc_states.get_mut(&uri) {
                    state.text = text;
                    state.version = version;
                }
            }
        }

        LspCommand::RequestCompletion { uri, row, col, trigger_char } => {
            log::debug!("lsp actor: request_completion {uri} {row}:{col}, trigger_char: {:?}", trigger_char);
            let server = actor.server.clone();
            let op_tx = actor.op_tx.clone();
            tokio::spawn(async move {
                let context = trigger_char.map(|c| CompletionContext {
                    trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
                    trigger_character: Some(c),
                });
                let params = CompletionParams {
                    text_document_position: TextDocumentPositionParams {
                        text_document: TextDocumentIdentifier {
                            uri: Url::parse(&uri).unwrap_or_else(|_| {
                                Url::parse("file:///unknown").unwrap()
                            }),
                        },
                        position: async_lsp::lsp_types::Position {
                            line: row,
                            character: col,
                        },
                    },
                    work_done_progress_params: Default::default(),
                    partial_result_params: Default::default(),
                    context,
                };
                match server.request::<request::Completion>(params).await {
                    Ok(Some(result)) => {
                        let items = match result {
                            CompletionResponse::Array(items) => items,
                            CompletionResponse::List(list) => list.items,
                        };
                        let parsed: Vec<LspCompletionItem> =
                            items.iter().map(completion_item_to_lsp).collect();
                        log::debug!("lsp: completion response — {} items", parsed.len());
                        let trigger = Some(crate::editor::position::Position::new(
                            row as usize,
                            col as usize,
                        ));
                        let _ = op_tx.send(vec![Operation::LspLocal(LspOp::CompletionResponse {
                            items: parsed,
                            trigger,
                            version: None,
                        })]);
                    }
                    Ok(None) => {}
                    Err(e) => log::debug!("[lsp] completion error: {e}"),
                }
            });
        }

        LspCommand::RequestHover { uri, row, col } => {
            if !actor.supports_hover {
                log::debug!("lsp actor: server does not support hover, skipping");
                return;
            }
            log::debug!("lsp actor: request_hover {uri} {row}:{col}");
            let server = actor.server.clone();
            let op_tx = actor.op_tx.clone();
            tokio::spawn(async move {
                let params = HoverParams {
                    text_document_position_params: TextDocumentPositionParams {
                        text_document: TextDocumentIdentifier {
                            uri: Url::parse(&uri).unwrap_or_else(|_| {
                                Url::parse("file:///unknown").unwrap()
                            }),
                        },
                        position: async_lsp::lsp_types::Position {
                            line: row,
                            character: col,
                        },
                    },
                    work_done_progress_params: Default::default(),
                };
                match server.request::<request::HoverRequest>(params).await {
                    Ok(Some(hover)) => {
                        let text = hover_to_string(hover);
                        let _ = op_tx
                            .send(vec![Operation::LspLocal(LspOp::HoverResponse(Some(text)))]);
                    }
                    Ok(None) => {
                        let _ = op_tx
                            .send(vec![Operation::LspLocal(LspOp::HoverResponse(None))]);
                    }
                    Err(e) => log::debug!("[lsp] hover error: {e}"),
                }
            });
        }

        LspCommand::RequestDefinition { uri, row, col } => {
            log::debug!("lsp actor: request_definition {uri} {row}:{col}");
            let server = actor.server.clone();
            let op_tx = actor.op_tx.clone();
            tokio::spawn(async move {
                let params = GotoDefinitionParams {
                    text_document_position_params: TextDocumentPositionParams {
                        text_document: TextDocumentIdentifier {
                            uri: Url::parse(&uri).unwrap_or_else(|_| {
                                Url::parse("file:///unknown").unwrap()
                            }),
                        },
                        position: async_lsp::lsp_types::Position {
                            line: row,
                            character: col,
                        },
                    },
                    work_done_progress_params: Default::default(),
                    partial_result_params: Default::default(),
                };
                match server.request::<request::GotoDefinition>(params).await {
                    Ok(Some(result)) => {
                        let locations = goto_to_locations(result);
                        let _ = op_tx.send(vec![Operation::LspGoToDefinitionResult(locations)]);
                    }
                    Ok(None) => {}
                    Err(e) => log::debug!("[lsp] definition error: {e}"),
                }
            });
        }


        LspCommand::RequestPrepareRename { uri, row, col, fallback_name } => {
            if !actor.supports_rename {
                log::debug!("lsp actor: server does not support rename, skipping");
                let _ = actor.op_tx.send(vec![Operation::LspRenameLocal(
                    LspRenameOp::OpenPrompt { current_name: fallback_name },
                )]);
                return;
            }
            if !actor.supports_prepare_rename {
                // Server supports rename but not prepareRename — open prompt with fallback name.
                let _ = actor.op_tx.send(vec![Operation::LspRenameLocal(
                    LspRenameOp::OpenPrompt { current_name: fallback_name },
                )]);
                return;
            }
            log::debug!("lsp actor: request_prepare_rename {uri} {row}:{col}");
            let server = actor.server.clone();
            let op_tx = actor.op_tx.clone();
            tokio::spawn(async move {
                let params = TextDocumentPositionParams {
                    text_document: TextDocumentIdentifier {
                        uri: Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap()),
                    },
                    position: Position { line: row, character: col },
                };
                match server.request::<request::PrepareRenameRequest>(params).await {
                    Ok(Some(result)) => {
                        use async_lsp::lsp_types::PrepareRenameResponse;
                        let current_name = match result {
                            PrepareRenameResponse::Range(_) => fallback_name,
                            PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. } => placeholder,
                            PrepareRenameResponse::DefaultBehavior { .. } => fallback_name,
                        };
                        let _ = op_tx.send(vec![Operation::LspRenameLocal(
                            LspRenameOp::OpenPrompt { current_name },
                        )]);
                    }
                    Ok(None) => {
                        // prepareRename returned null — use the fallback name.
                        let _ = op_tx.send(vec![Operation::LspRenameLocal(
                            LspRenameOp::OpenPrompt { current_name: fallback_name },
                        )]);
                    }
                    Err(e) => {
                        log::debug!("[lsp] prepareRename error: {e}");
                        let _ = op_tx.send(vec![Operation::LspRenameLocal(
                            LspRenameOp::OpenPrompt { current_name: fallback_name },
                        )]);
                    }
                }
            });
        }

        LspCommand::RequestRename { uri, row, col, new_name } => {
            if !actor.supports_rename {
                log::debug!("lsp actor: server does not support rename, skipping");
                return;
            }
            log::debug!("lsp actor: request_rename {uri} {row}:{col} -> {new_name:?}");
            let server = actor.server.clone();
            let op_tx = actor.op_tx.clone();
            tokio::spawn(async move {
                let params = RenameParams {
                    text_document_position: TextDocumentPositionParams {
                        text_document: TextDocumentIdentifier {
                            uri: Url::parse(&uri).unwrap_or_else(|_| Url::parse("file:///unknown").unwrap()),
                        },
                        position: Position { line: row, character: col },
                    },
                    new_name,
                    work_done_progress_params: Default::default(),
                };
                match server.request::<request::Rename>(params).await {
                    Ok(Some(workspace_edit)) => {
                        let file_edits = workspace_edit_to_file_edits(workspace_edit);
                        let _ = op_tx.send(vec![Operation::LspRenameResult(file_edits)]);
                    }
                    Ok(None) => {
                        log::debug!("[lsp] rename: server returned no edits");
                    }
                    Err(e) => log::debug!("[lsp] rename error: {e}"),
                }
            });
        }

        LspCommand::Shutdown => {
            log::debug!("lsp actor: shutdown requested");
            // Best-effort: request `shutdown` and notify `exit`. Ignore errors.
            let _ = actor.server.request::<request::Shutdown>(()).await;
            let _ = actor.server.notify::<notification::Exit>(());
            // After notifying exit, let the actor continue; the server/mainloop
            // should terminate shortly. Do not explicitly drop `server` here —
            // dropping occurs when actor exits or is aborted.
        }
    }
}

// ---------------------------------------------------------------------------
// publishDiagnostics → IssueRegistry wiring
// ---------------------------------------------------------------------------

fn handle_publish_diagnostics(
    op_tx: &mpsc::UnboundedSender<Vec<Operation>>,
    server_name: &str,
    params: async_lsp::lsp_types::PublishDiagnosticsParams,
) {
    let uri_str = params.uri.to_string();
    let marker = format!("lsp:{uri_str}");
    let path = uri_to_path(&uri_str);

    let mut ops = vec![Operation::ClearIssuesByMarker { marker: marker.clone() }];
    for diag in &params.diagnostics {
        let severity = lsp_severity_to_issue(diag.severity);
        let range = lsp_range_to_position_range(&diag.range);
        ops.push(Operation::AddIssue {
            issue: crate::issue_registry::NewIssue {
                marker: Some(marker.clone()),
                source: server_name.to_owned(),
                path: Some(path.clone()),
                range: Some(range),
                message: diag.message.clone(),
                severity,
            },
        });
    }
    let _ = op_tx.send(ops);
}

fn lsp_severity_to_issue(
    sev: Option<async_lsp::lsp_types::DiagnosticSeverity>,
) -> crate::issue_registry::Severity {
    use async_lsp::lsp_types::DiagnosticSeverity;
    use crate::issue_registry::Severity;
    match sev {
        Some(DiagnosticSeverity::ERROR) => Severity::Error,
        Some(DiagnosticSeverity::WARNING) => Severity::Warning,
        Some(DiagnosticSeverity::INFORMATION) => Severity::Info,
        // HINT and unspecified severity both map to Info — hints are informational,
        // not action items, so Todo (reserved for TODO/FIXME code comments) is wrong.
        _ => Severity::Info,
    }
}

fn lsp_range_to_position_range(
    range: &async_lsp::lsp_types::Range,
) -> (crate::editor::position::Position, crate::editor::position::Position) {
    let start = crate::editor::position::Position::new(
        range.start.line as usize,
        range.start.character as usize,
    );
    let end = crate::editor::position::Position::new(
        range.end.line as usize,
        range.end.character as usize,
    );
    (start, end)
}

// ---------------------------------------------------------------------------
// Typed conversion helpers (replacing JSON-based parse_completion_items etc.)
// ---------------------------------------------------------------------------

fn completion_item_to_lsp(item: &async_lsp::lsp_types::CompletionItem) -> LspCompletionItem {
    LspCompletionItem {
        label: item.label.clone(),
        kind: item.kind.and_then(|k| {
            serde_json::to_value(k).ok().and_then(|v| v.as_u64()).map(|n| n as u8)
        }),
        detail: item.detail.clone(),
        insert_text: item.insert_text.clone(),
    }
}

fn hover_to_string(hover: async_lsp::lsp_types::Hover) -> String {
    match hover.contents {
        HoverContents::Scalar(MarkedString::String(s)) => s,
        HoverContents::Scalar(MarkedString::LanguageString(ls)) => ls.value,
        HoverContents::Array(parts) => parts
            .into_iter()
            .map(|p| match p {
                MarkedString::String(s) => s,
                MarkedString::LanguageString(ls) => ls.value,
            })
            .collect::<Vec<_>>()
            .join("\n\n"),
        HoverContents::Markup(m) => m.value,
    }
}

fn goto_to_locations(result: GotoDefinitionResponse) -> Vec<LspLocation> {
    let locs = match result {
        GotoDefinitionResponse::Scalar(loc) => vec![loc],
        GotoDefinitionResponse::Array(locs) => locs,
        GotoDefinitionResponse::Link(links) => links
            .into_iter()
            .map(|l| async_lsp::lsp_types::Location {
                uri: l.target_uri,
                range: l.target_range,
            })
            .collect(),
    };
    locs.into_iter()
        .map(|loc| LspLocation {
            path: uri_to_path(loc.uri.as_str()),
            row: loc.range.start.line as usize,
            col: loc.range.start.character as usize,
        })
        .collect()
}

fn build_client_capabilities() -> ClientCapabilities {
    ClientCapabilities {
        text_document: Some(TextDocumentClientCapabilities {
            completion: Some(CompletionClientCapabilities {
                completion_item: Some(CompletionItemCapability {
                    snippet_support: Some(false),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            hover: Some(HoverClientCapabilities::default()),
            definition: Some(GotoCapability::default()),
            publish_diagnostics: Some(PublishDiagnosticsClientCapabilities::default()),
            ..Default::default()
        }),
        ..Default::default()
    }
}

// Helper: compute the minimal changed byte ranges between old and new strings.
// Returns (old_start, old_end, new_start, new_end) — byte indices suitable for
// slicing the respective strings at UTF-8 char boundaries.
fn compute_change_range(old: &str, new: &str) -> (usize, usize, usize, usize) {
    if old == new {
        return (0, 0, 0, 0);
    }
    let old_chars: Vec<(usize, char)> = old.char_indices().collect();
    let new_chars: Vec<(usize, char)> = new.char_indices().collect();

    // common prefix (in whole chars)
    let mut i = 0usize;
    while i < old_chars.len() && i < new_chars.len() && old_chars[i].1 == new_chars[i].1 {
        i += 1;
    }
    let old_prefix_byte = if i == 0 {
        0
    } else {
        let (b, c) = old_chars[i - 1];
        b + c.len_utf8()
    };
    let new_prefix_byte = if i == 0 {
        0
    } else {
        let (b, c) = new_chars[i - 1];
        b + c.len_utf8()
    };

    // common suffix (in whole chars), but don't overlap the prefix
    let mut suffix = 0usize;
    while suffix < old_chars.len() - i && suffix < new_chars.len() - i
        && old_chars[old_chars.len() - 1 - suffix].1
            == new_chars[new_chars.len() - 1 - suffix].1
    {
        suffix += 1;
    }
    let old_end_byte = if suffix == 0 {
        old.len()
    } else {
        old_chars[old_chars.len() - suffix].0
    };
    let new_end_byte = if suffix == 0 {
        new.len()
    } else {
        new_chars[new_chars.len() - suffix].0
    };

    (old_prefix_byte, old_end_byte, new_prefix_byte, new_end_byte)
}

// Convert a byte index into (line, character) using char counts per line.
fn byte_index_to_position(text: &str, byte_idx: usize) -> (u32, u32) {
    let prefix = &text[..byte_idx.min(text.len())];
    let line = prefix.matches('\n').count() as u32;
    let last_newline = prefix.rfind('\n').map(|i| i + 1).unwrap_or(0);
    let character = prefix[last_newline..].chars().count() as u32;
    (line, character)
}

// ---------------------------------------------------------------------------
// WorkspaceEdit → LspFileEdit conversion
// ---------------------------------------------------------------------------

/// Convert an LSP `WorkspaceEdit` into our flat `Vec<LspFileEdit>`.
///
/// Handles `changes` (the simpler `{ uri: [TextEdit] }` map) and the
/// `documentChanges` array (only `TextDocumentEdit` entries — file create /
/// rename / delete are ignored for now).
fn workspace_edit_to_file_edits(edit: WorkspaceEdit) -> Vec<LspFileEdit> {
    let mut map: std::collections::HashMap<std::path::PathBuf, Vec<LspTextEdit>> =
        std::collections::HashMap::new();

    // --- changes ---
    if let Some(changes) = edit.changes {
        for (uri, text_edits) in changes {
            let path = uri_to_path(uri.as_str());
            let entry = map.entry(path).or_default();
            for te in text_edits {
                entry.push(lsp_text_edit_to_ours(te));
            }
        }
    }

    // --- documentChanges (supersedes `changes` if present) ---
    if let Some(doc_changes) = edit.document_changes {
        map.clear();
        use async_lsp::lsp_types::DocumentChanges;
        match doc_changes {
            DocumentChanges::Edits(edits) => {
                for te in edits {
                    let path = uri_to_path(te.text_document.uri.as_str());
                    let entry = map.entry(path).or_default();
                    for e in te.edits {
                        use async_lsp::lsp_types::OneOf;
                        match e {
                            OneOf::Left(text_edit) => {
                                entry.push(lsp_text_edit_to_ours(text_edit));
                            }
                            OneOf::Right(annotated) => {
                                entry.push(lsp_text_edit_to_ours(annotated.text_edit));
                            }
                        }
                    }
                }
            }
            DocumentChanges::Operations(_) => {
                // CreateFile / RenameFile / DeleteFile — not handled yet.
            }
        }
    }

    map.into_iter()
        .map(|(path, edits)| LspFileEdit { path, edits })
        .collect()
}

fn lsp_text_edit_to_ours(te: TextEdit) -> LspTextEdit {
    LspTextEdit {
        start_line: te.range.start.line,
        start_char: te.range.start.character,
        end_line: te.range.end.line,
        end_char: te.range.end.character,
        new_text: te.new_text,
    }
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn lsp_severity_mapping() {
        use crate::issue_registry::Severity;
        assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::ERROR)), Severity::Error);
        assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::WARNING)), Severity::Warning);
        assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::INFORMATION)), Severity::Info);
        // HINT is informational, not a TODO/FIXME annotation — maps to Info.
        assert_eq!(lsp_severity_to_issue(Some(DiagnosticSeverity::HINT)), Severity::Info);
        // Unspecified severity is treated as informational.
        assert_eq!(lsp_severity_to_issue(None), Severity::Info);
    }

    #[test]
    fn completion_item_mapping() {
        let item = CompletionItem {
            label: "println!".to_string(),
            kind: Some(CompletionItemKind::FUNCTION),
            detail: Some("fn println(...)".to_string()),
            insert_text: Some("println!($0)".to_string()),
            ..Default::default()
        };
        let lsp_item = completion_item_to_lsp(&item);
        assert_eq!(lsp_item.label, "println!");
        assert_eq!(lsp_item.detail.as_deref(), Some("fn println(...)"));
    }

    #[tokio::test]
    async fn publish_diagnostics_emits_operations() {
        use tokio::sync::mpsc;
        let (tx, mut rx) = mpsc::unbounded_channel::<Vec<crate::operation::Operation>>();

        let params = PublishDiagnosticsParams {
            uri: Url::parse("file:///project/src/main.rs").unwrap(),
            version: None,
            diagnostics: vec![Diagnostic {
                range: Range {
                    start: Position { line: 5, character: 4 },
                    end: Position { line: 5, character: 10 },
                },
                severity: Some(DiagnosticSeverity::ERROR),
                message: "cannot find value `foo`".to_string(),
                ..Default::default()
            }],
        };

        handle_publish_diagnostics(&tx, "rust", params);

        let ops = rx.recv().await.unwrap();
        assert!(
            matches!(&ops[0], crate::operation::Operation::ClearIssuesByMarker { marker }
                if marker.starts_with("lsp:")),
            "first op should be ClearIssuesByMarker"
        );
        if let crate::operation::Operation::AddIssue { issue } = &ops[1] {
            assert_eq!(issue.severity, crate::issue_registry::Severity::Error);
            assert_eq!(issue.message, "cannot find value `foo`");
            assert_eq!(issue.source, "rust", "source should equal server_name");
        } else {
            panic!("expected AddIssue at ops[1]");
        }
    }

    #[tokio::test]
    async fn publish_diagnostics_hint_maps_to_info() {
        use tokio::sync::mpsc;
        let (tx, mut rx) = mpsc::unbounded_channel::<Vec<crate::operation::Operation>>();
        let params = PublishDiagnosticsParams {
            uri: Url::parse("file:///project/src/lib.rs").unwrap(),
            version: None,
            diagnostics: vec![Diagnostic {
                range: Range {
                    start: Position { line: 0, character: 0 },
                    end: Position { line: 0, character: 1 },
                },
                severity: Some(DiagnosticSeverity::HINT),
                message: "consider using a semicolon".to_string(),
                ..Default::default()
            }],
        };
        handle_publish_diagnostics(&tx, "typescript", params);
        let ops = rx.recv().await.unwrap();
        if let crate::operation::Operation::AddIssue { issue } = &ops[1] {
            assert_eq!(issue.severity, crate::issue_registry::Severity::Info,
                "HINT should map to Info, not Todo");
            assert_eq!(issue.source, "typescript");
        } else {
            panic!("expected AddIssue at ops[1]");
        }
    }

    #[test]
    fn hover_to_string_markup() {
        let hover = Hover {
            contents: HoverContents::Markup(MarkupContent {
                kind: MarkupKind::Markdown,
                value: "**Description**\n\nSome docs".to_string(),
            }),
            range: None,
        };
        let s = hover_to_string(hover);
        assert_eq!(s, "**Description**\n\nSome docs");
    }

    #[test]
    fn hover_to_string_scalar_string() {
        let hover = Hover {
            contents: HoverContents::Scalar(MarkedString::String("plain text".to_string())),
            range: None,
        };
        assert_eq!(hover_to_string(hover), "plain text");
    }

    #[test]
    fn uri_to_path_posix() {
        if !cfg!(windows) {
            let p = uri_to_path("file:///home/user/foo.rs");
            assert_eq!(p, std::path::PathBuf::from("/home/user/foo.rs"));
        }
    }
}
