//! Local, non-persistent text selection state for the single-Worker transcript view.
//!
//! This module deliberately stores only the most recent rendered history rows and
//! the active drag endpoints. Selected/copied text never leaves TUI-local state
//! unless the user explicitly presses the copy key, and even then the caller is
//! responsible for using a non-history clipboard path.

use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionPoint {
    pub row: usize,
    pub col: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HistoryViewport {
    pub x: u16,
    pub y: u16,
    pub width: u16,
    pub height: u16,
    pub top_offset: usize,
    pub total_lines: usize,
}

impl HistoryViewport {
    pub fn contains(self, col: u16, row: u16) -> bool {
        col >= self.x
            && col < self.x.saturating_add(self.width)
            && row >= self.y
            && row < self.y.saturating_add(self.height)
    }

    pub fn point_at(self, col: u16, row: u16) -> Option<SelectionPoint> {
        if !self.contains(col, row) {
            return None;
        }
        Some(SelectionPoint {
            row: self.top_offset + row.saturating_sub(self.y) as usize,
            col: col.saturating_sub(self.x) as usize,
        })
    }

    pub fn clamped_point_at(self, col: u16, row: u16) -> Option<SelectionPoint> {
        if self.width == 0 || self.height == 0 || self.total_lines == 0 {
            return None;
        }
        let max_col = self.x.saturating_add(self.width.saturating_sub(1));
        let max_row = self.y.saturating_add(self.height.saturating_sub(1));
        let col = col.clamp(self.x, max_col);
        let row = row.clamp(self.y, max_row);
        let absolute_row = self.top_offset + row.saturating_sub(self.y) as usize;
        if absolute_row >= self.total_lines {
            return None;
        }
        Some(SelectionPoint {
            row: absolute_row,
            col: col.saturating_sub(self.x) as usize,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectionRow {
    pub text: String,
    /// Whether this rendered row belongs to selectable transcript text. Tool
    /// calls, thinking blocks, greetings, notices, stats, and other non-text
    /// items are intentionally false.
    pub selectable: bool,
}

impl SelectionRow {
    pub fn new(text: String, selectable: bool) -> Self {
        Self { text, selectable }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveSelection {
    pub anchor: SelectionPoint,
    pub focus: SelectionPoint,
    pub dragging: bool,
}

#[derive(Debug, Clone, Default)]
pub struct TextSelectionState {
    active: Option<ActiveSelection>,
    viewport: Option<HistoryViewport>,
    rows: Vec<SelectionRow>,
}

impl TextSelectionState {
    pub fn set_history_snapshot(&mut self, viewport: HistoryViewport, rows: Vec<SelectionRow>) {
        let rows_changed = self.rows != rows;
        self.viewport = Some(viewport);
        self.rows = rows;
        if rows_changed {
            self.active = None;
        } else {
            self.drop_selection_if_stale();
        }
    }

    pub fn clear_history_snapshot(&mut self) {
        self.viewport = None;
        self.rows.clear();
        self.active = None;
    }

    #[cfg(test)]
    pub fn active(&self) -> Option<ActiveSelection> {
        self.active
    }

    pub fn has_selection(&self) -> bool {
        self.active.is_some()
    }

    pub fn clear(&mut self) -> bool {
        self.active.take().is_some()
    }

    pub fn begin_drag(&mut self, col: u16, row: u16) -> bool {
        let Some(point) = self
            .viewport
            .and_then(|viewport| viewport.point_at(col, row))
        else {
            return false;
        };
        if !self.row_is_selectable(point.row) {
            self.active = None;
            return false;
        }
        self.active = Some(ActiveSelection {
            anchor: point,
            focus: point,
            dragging: true,
        });
        true
    }

    pub fn update_drag(&mut self, col: u16, row: u16) -> bool {
        let Some(mut active) = self.active else {
            return false;
        };
        if !active.dragging {
            return false;
        }
        let Some(point) = self
            .viewport
            .and_then(|viewport| viewport.clamped_point_at(col, row))
        else {
            return false;
        };
        active.focus = point;
        self.active = Some(active);
        true
    }

    pub fn finish_drag(&mut self, col: u16, row: u16) -> bool {
        let updated = self.update_drag(col, row);
        if let Some(active) = self.active.as_mut() {
            active.dragging = false;
            true
        } else {
            updated
        }
    }

    pub fn copy_text(&self) -> Option<String> {
        let active = self.active?;
        selected_text_from_rows(&self.rows, active.anchor, active.focus)
    }

    pub fn range_for_row(&self, row: usize) -> Option<(usize, usize)> {
        let active = self.active?;
        let (start, end) = ordered_points(active.anchor, active.focus);
        if row < start.row || row > end.row || !self.row_is_selectable(row) {
            return None;
        }
        let row_width = display_width(&self.rows.get(row)?.text);
        let (from, to) = if start.row == end.row {
            (
                start.col.min(row_width),
                end.col.saturating_add(1).min(row_width),
            )
        } else if row == start.row {
            (start.col.min(row_width), row_width)
        } else if row == end.row {
            (0, end.col.saturating_add(1).min(row_width))
        } else {
            (0, row_width)
        };
        if from >= to { None } else { Some((from, to)) }
    }

    fn row_is_selectable(&self, row: usize) -> bool {
        self.rows.get(row).is_some_and(|row| row.selectable)
    }

    fn drop_selection_if_stale(&mut self) {
        let Some(active) = self.active else {
            return;
        };
        if active.anchor.row >= self.rows.len() || active.focus.row >= self.rows.len() {
            self.active = None;
        }
    }
}

pub fn selected_text_from_rows(
    rows: &[SelectionRow],
    anchor: SelectionPoint,
    focus: SelectionPoint,
) -> Option<String> {
    let (start, end) = ordered_points(anchor, focus);
    if start.row >= rows.len() || end.row >= rows.len() {
        return None;
    }

    let mut copied = Vec::new();
    for row_idx in start.row..=end.row {
        let row = &rows[row_idx];
        if !row.selectable {
            continue;
        }
        let row_width = display_width(&row.text);
        let (from, to) = if start.row == end.row {
            (
                start.col.min(row_width),
                end.col.saturating_add(1).min(row_width),
            )
        } else if row_idx == start.row {
            (start.col.min(row_width), row_width)
        } else if row_idx == end.row {
            (0, end.col.saturating_add(1).min(row_width))
        } else {
            (0, row_width)
        };
        copied.push(slice_display_cols(&row.text, from, to));
    }

    if copied.iter().any(|line| !line.is_empty()) {
        Some(copied.join("\n"))
    } else {
        None
    }
}

pub fn ordered_points(a: SelectionPoint, b: SelectionPoint) -> (SelectionPoint, SelectionPoint) {
    if (a.row, a.col) <= (b.row, b.col) {
        (a, b)
    } else {
        (b, a)
    }
}

pub fn display_width(text: &str) -> usize {
    UnicodeWidthStr::width(text)
}

pub fn slice_display_cols(text: &str, start: usize, end: usize) -> String {
    if start >= end {
        return String::new();
    }

    let mut out = String::new();
    let mut col = 0usize;
    for c in text.chars() {
        let width = UnicodeWidthChar::width(c).unwrap_or(0);
        let next = col.saturating_add(width);
        if next > start && col < end {
            out.push(c);
        }
        col = next;
        if col >= end {
            break;
        }
    }
    out
}

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

    #[test]
    fn coordinate_mapping_uses_history_inner_origin_and_scroll_offset() {
        let viewport = HistoryViewport {
            x: 2,
            y: 3,
            width: 10,
            height: 5,
            top_offset: 7,
            total_lines: 20,
        };

        assert_eq!(
            viewport.point_at(4, 5),
            Some(SelectionPoint { row: 9, col: 2 })
        );
        assert_eq!(viewport.point_at(1, 5), None);
        assert_eq!(viewport.point_at(4, 8), None);
    }

    #[test]
    fn selection_state_drag_update_and_clear() {
        let mut state = TextSelectionState::default();
        state.set_history_snapshot(
            HistoryViewport {
                x: 0,
                y: 0,
                width: 20,
                height: 3,
                top_offset: 0,
                total_lines: 3,
            },
            vec![
                SelectionRow::new("alpha".into(), true),
                SelectionRow::new("tool".into(), false),
                SelectionRow::new("omega".into(), true),
            ],
        );

        assert!(state.begin_drag(1, 0));
        assert!(state.update_drag(2, 2));
        assert_eq!(state.copy_text().as_deref(), Some("lpha\nome"));
        assert!(state.finish_drag(2, 2));
        assert!(!state.active().unwrap().dragging);
        assert!(state.clear());
        assert!(!state.has_selection());
    }

    #[test]
    fn selected_text_preserves_blank_separator_between_text_items() {
        let rows = vec![
            SelectionRow::new("first".into(), true),
            SelectionRow::new("".into(), true),
            SelectionRow::new("second".into(), true),
        ];

        let copied = selected_text_from_rows(
            &rows,
            SelectionPoint { row: 0, col: 0 },
            SelectionPoint { row: 2, col: 5 },
        )
        .unwrap();
        assert_eq!(copied, "first\n\nsecond");
    }

    #[test]
    fn non_text_rows_are_skipped_during_extraction() {
        let rows = vec![
            SelectionRow::new("user".into(), true),
            SelectionRow::new("tool output".into(), false),
            SelectionRow::new("assistant".into(), true),
        ];

        let copied = selected_text_from_rows(
            &rows,
            SelectionPoint { row: 0, col: 0 },
            SelectionPoint { row: 2, col: 8 },
        )
        .unwrap();
        assert_eq!(copied, "user\nassistant");
    }
}
