//! Self-contained hover state.
//!
//! Owns five fields previously scattered on `Editor`:
//!
//! - In-flight LSP hover request: `(request_id, lsp_position)`. Used to
//!   ignore stale responses from earlier mouse moves.
//! - Highlighted-symbol range and its overlay handle. The handle is kept
//!   so we can remove the old overlay before drawing a new one, or when
//!   focus is lost.
//! - Cached mouse screen position. Set at request time so the popup can
//!   be placed under the same cell the user last hovered, even if they
//!   have since moved the mouse elsewhere.
//!
//! All cross-cutting effects — sending the LSP request, applying the
//! `RemoveOverlay` event to the buffer after `take_symbol_overlay`,
//! positioning the popup — stay on `Editor` as orchestrators. This module
//! is pure data with no `Editor` or I/O access.

use crate::view::overlay::OverlayHandle;
use std::collections::HashSet;

/// One server's contribution to a hover card. Several servers may answer a
/// single hover request; their non-null payloads are accumulated and merged
/// into one popup.
#[derive(Debug, Clone)]
pub(crate) struct HoverPayload {
    /// Raw hover body text (markdown or plain), already normalized so that
    /// null / empty LSP hovers never reach here.
    pub(crate) contents: String,
    /// Whether `contents` should be rendered as markdown.
    pub(crate) is_markdown: bool,
    /// LSP range of the hovered symbol, if the server provided one.
    pub(crate) range: Option<((u32, u32), (u32, u32))>,
    /// Originating server name. Carried so per-server labels can be added
    /// later; unused for rendering today.
    #[allow(dead_code)]
    pub(crate) server_name: Option<String>,
}

/// Owner of in-flight hover request(s) and highlighted-symbol tracking.
#[derive(Debug, Default)]
pub(crate) struct HoverState {
    /// LSP request ids of the in-flight hover requests. Hover fans out to
    /// every capable server, so multiple ids may be outstanding at once.
    /// Used to ignore stale responses from earlier mouse moves.
    pending_requests: HashSet<u64>,
    /// LSP position `(line, character)` of the in-flight batch. Retained
    /// so the response handler can correlate diagnostics with the hover
    /// point and fuse them into the hover card.
    pending_position: Option<(u32, u32)>,
    /// Non-null hover payloads collected so far for the current batch, in
    /// arrival order. Merged into a single popup once responses are in.
    accumulated: Vec<HoverPayload>,
    /// Byte range `(start, end)` of the currently-highlighted symbol.
    /// Used by mouse-move handlers to detect "still on same symbol" and
    /// skip re-querying.
    symbol_range: Option<(usize, usize)>,
    /// Overlay handle for the symbol highlight, so the caller can remove
    /// the previous highlight via `RemoveOverlay` before adding a new one.
    symbol_overlay: Option<OverlayHandle>,
    /// Screen cell `(col, row)` where the popup should be placed. Set
    /// when a mouse-triggered hover request is fired; consumed when the
    /// popup is rendered.
    screen_position: Option<(u16, u16)>,
}

impl HoverState {
    // ---- Pending-request correlation --------------------------------------

    /// Record that a batch of hover requests (`ids`) was sent at LSP
    /// position `(line, character)`. Replaces any previous batch and clears
    /// the accumulator so responses from the prior batch are ignored.
    pub(crate) fn record_requests(&mut self, ids: &[u64], line: u32, character: u32) {
        self.pending_requests = ids.iter().copied().collect();
        self.pending_position = Some((line, character));
        self.accumulated.clear();
    }

    /// Claim a response as belonging to the in-flight batch. If `request_id`
    /// is one of the outstanding ids it is removed and the batch position is
    /// returned for diagnostic correlation. The position is only drained once
    /// the last outstanding id is claimed, so every response in the batch can
    /// still correlate diagnostics.
    ///
    /// Returns `None` if the response is stale — the caller should drop it.
    pub(crate) fn claim_pending(&mut self, request_id: u64) -> Option<(u32, u32)> {
        if !self.pending_requests.remove(&request_id) {
            return None;
        }
        if self.pending_requests.is_empty() {
            self.pending_position.take()
        } else {
            self.pending_position
        }
    }

    /// Whether every outstanding request in the batch has been claimed.
    pub(crate) fn pending_is_empty(&self) -> bool {
        self.pending_requests.is_empty()
    }

    /// Append a non-null hover payload to the current batch's accumulator.
    pub(crate) fn push_payload(&mut self, payload: HoverPayload) {
        self.accumulated.push(payload);
    }

    /// Payloads accumulated for the current batch, in arrival order.
    pub(crate) fn accumulated(&self) -> &[HoverPayload] {
        &self.accumulated
    }

    /// Clear any in-flight batch without consuming a position — used
    /// when focus is lost or the user cancels hover.
    pub(crate) fn clear_pending(&mut self) {
        self.pending_requests.clear();
        self.pending_position = None;
        self.accumulated.clear();
    }

    // ---- Symbol range -----------------------------------------------------

    pub(crate) fn symbol_range(&self) -> Option<(usize, usize)> {
        self.symbol_range
    }

    pub(crate) fn set_symbol_range(&mut self, range: Option<(usize, usize)>) {
        self.symbol_range = range;
    }

    // ---- Symbol overlay handle --------------------------------------------

    /// Take the current overlay handle (if any) so the caller can apply a
    /// `RemoveOverlay` event to the buffer before adding a new overlay.
    pub(crate) fn take_symbol_overlay(&mut self) -> Option<OverlayHandle> {
        self.symbol_overlay.take()
    }

    pub(crate) fn set_symbol_overlay(&mut self, handle: OverlayHandle) {
        self.symbol_overlay = Some(handle);
    }

    // ---- Screen position --------------------------------------------------

    pub(crate) fn set_screen_position(&mut self, pos: (u16, u16)) {
        self.screen_position = Some(pos);
    }

    pub(crate) fn take_screen_position(&mut self) -> Option<(u16, u16)> {
        self.screen_position.take()
    }
}

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

    #[test]
    fn default_is_empty() {
        let mut h = HoverState::default();
        assert_eq!(h.symbol_range(), None);
        assert_eq!(h.claim_pending(42), None);
    }

    #[test]
    fn claim_pending_returns_position_and_clears_state() {
        let mut h = HoverState::default();
        h.record_requests(&[7], 10, 20);
        assert!(!h.pending_is_empty());
        assert_eq!(h.claim_pending(7), Some((10, 20)));
        // Batch now empty.
        assert!(h.pending_is_empty());
        // Subsequent claim for the same id returns None — id removed.
        assert_eq!(h.claim_pending(7), None);
    }

    #[test]
    fn claim_pending_across_multi_server_batch_keeps_position_until_last() {
        let mut h = HoverState::default();
        h.record_requests(&[1, 2], 3, 4);
        // First response: position returned, batch not yet empty.
        assert_eq!(h.claim_pending(1), Some((3, 4)));
        assert!(!h.pending_is_empty());
        // Second (last) response: position still returned, batch now empty.
        assert_eq!(h.claim_pending(2), Some((3, 4)));
        assert!(h.pending_is_empty());
    }

    #[test]
    fn claim_pending_rejects_stale_response() {
        let mut h = HoverState::default();
        h.record_requests(&[7], 10, 20);
        // Older response arrives after a newer request went out.
        assert_eq!(h.claim_pending(3), None);
        // Correct one still works.
        assert_eq!(h.claim_pending(7), Some((10, 20)));
    }

    #[test]
    fn record_requests_overwrites_previous_batch() {
        let mut h = HoverState::default();
        h.record_requests(&[1], 0, 0);
        h.record_requests(&[2], 5, 5);
        assert_eq!(h.claim_pending(1), None);
        assert_eq!(h.claim_pending(2), Some((5, 5)));
    }

    #[test]
    fn record_requests_clears_accumulator() {
        let mut h = HoverState::default();
        h.record_requests(&[1], 0, 0);
        h.push_payload(HoverPayload {
            contents: "a".to_string(),
            is_markdown: false,
            range: None,
            server_name: None,
        });
        assert_eq!(h.accumulated().len(), 1);
        h.record_requests(&[2], 0, 0);
        assert!(h.accumulated().is_empty());
    }

    #[test]
    fn push_payload_accumulates_in_order() {
        let mut h = HoverState::default();
        h.record_requests(&[1, 2], 0, 0);
        h.push_payload(HoverPayload {
            contents: "first".to_string(),
            is_markdown: false,
            range: None,
            server_name: Some("A".to_string()),
        });
        h.push_payload(HoverPayload {
            contents: "second".to_string(),
            is_markdown: true,
            range: Some(((1, 2), (3, 4))),
            server_name: Some("B".to_string()),
        });
        let acc = h.accumulated();
        assert_eq!(acc.len(), 2);
        assert_eq!(acc[0].contents, "first");
        assert_eq!(acc[1].contents, "second");
        assert_eq!(acc[1].range, Some(((1, 2), (3, 4))));
    }

    #[test]
    fn clear_pending_drops_without_returning_position() {
        let mut h = HoverState::default();
        h.record_requests(&[7], 10, 20);
        h.clear_pending();
        assert!(h.pending_is_empty());
        assert_eq!(h.claim_pending(7), None);
    }

    #[test]
    fn symbol_range_roundtrips() {
        let mut h = HoverState::default();
        assert_eq!(h.symbol_range(), None);
        h.set_symbol_range(Some((10, 20)));
        assert_eq!(h.symbol_range(), Some((10, 20)));
        h.set_symbol_range(None);
        assert_eq!(h.symbol_range(), None);
    }

    #[test]
    fn take_screen_position_drains_on_first_call() {
        let mut h = HoverState::default();
        h.set_screen_position((15, 8));
        assert_eq!(h.take_screen_position(), Some((15, 8)));
        assert_eq!(h.take_screen_position(), None);
    }
}
