//! HTML diffing for condensed page state.
//!
//! This module provides efficient tracking of HTML changes between rounds,
//! enabling 50-70% token reduction by only sending changed content to the LLM.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::fnv1a64;

/// Mode for HTML diffing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HtmlDiffMode {
    /// Disabled - always send full HTML.
    #[default]
    Disabled,
    /// Enabled - send diffs after first round.
    Enabled,
    /// Auto - enable if HTML exceeds threshold.
    Auto,
}

impl HtmlDiffMode {
    /// Whether to use diffing based on mode and HTML size.
    pub fn should_diff(&self, html_bytes: usize, round: usize) -> bool {
        match self {
            HtmlDiffMode::Disabled => false,
            HtmlDiffMode::Enabled => round > 0,
            HtmlDiffMode::Auto => {
                // Auto-enable for large HTML after first round
                round > 0 && html_bytes > 10_000
            }
        }
    }
}

/// Tracker for page state changes across rounds.
#[derive(Debug, Clone)]
pub struct PageStateDiff {
    /// Hashes of elements by their pseudo-selector/path.
    element_hashes: HashMap<String, u64>,
    /// Previous full HTML (for diff computation).
    previous_html: Option<String>,
    /// Previous URL.
    previous_url: Option<String>,
    /// Previous title.
    previous_title: Option<String>,
    /// Round number.
    round: usize,
}

impl Default for PageStateDiff {
    fn default() -> Self {
        Self::new()
    }
}

impl PageStateDiff {
    /// Create a new empty state tracker.
    pub fn new() -> Self {
        Self {
            element_hashes: HashMap::new(),
            previous_html: None,
            previous_url: None,
            previous_title: None,
            round: 0,
        }
    }

    /// Reset the tracker to initial state.
    pub fn reset(&mut self) {
        self.element_hashes.clear();
        self.previous_html = None;
        self.previous_url = None;
        self.previous_title = None;
        self.round = 0;
    }

    /// Update state with new page content.
    ///
    /// Returns the diff result for this update.
    pub fn update(&mut self, html: &str, url: &str, title: &str) -> HtmlDiffResult {
        let new_hashes = Self::compute_element_hashes(html);

        let diff = if self.round == 0 {
            // First round - no previous state
            HtmlDiffResult::initial(html, url, title)
        } else {
            self.compute_diff(html, url, title, &new_hashes)
        };

        // Update stored state
        self.element_hashes = new_hashes;
        self.previous_html = Some(html.to_string());
        self.previous_url = Some(url.to_string());
        self.previous_title = Some(title.to_string());
        self.round += 1;

        diff
    }

    /// Compute diff against previous state.
    fn compute_diff(
        &self,
        html: &str,
        url: &str,
        title: &str,
        new_hashes: &HashMap<String, u64>,
    ) -> HtmlDiffResult {
        let mut result = HtmlDiffResult {
            is_initial: false,
            url_changed: self.previous_url.as_deref() != Some(url),
            title_changed: self.previous_title.as_deref() != Some(title),
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 0,
            condensed_html: None,
            full_html: None,
            savings_ratio: 0.0,
        };

        // Compare element hashes
        for (path, new_hash) in new_hashes {
            if let Some(old_hash) = self.element_hashes.get(path) {
                if new_hash != old_hash {
                    // Changed element
                    if let Some(content) = Self::extract_element_content(html, path) {
                        result.changed.push(ElementChange {
                            path: path.clone(),
                            change_type: ChangeType::ContentChanged,
                            content: Some(content),
                        });
                    }
                } else {
                    result.unchanged_count += 1;
                }
            } else {
                // New element
                if let Some(content) = Self::extract_element_content(html, path) {
                    result.added.push(content);
                }
            }
        }

        // Find removed elements
        for path in self.element_hashes.keys() {
            if !new_hashes.contains_key(path) {
                result.removed.push(path.clone());
            }
        }

        // Build condensed HTML if there are significant changes
        let original_len = html.len();
        if !result.changed.is_empty() || !result.added.is_empty() || !result.removed.is_empty() {
            let condensed = result.build_condensed_context(url, title);
            let condensed_len = condensed.len();
            result.condensed_html = Some(condensed);
            result.savings_ratio = 1.0 - (condensed_len as f64 / original_len as f64);
        } else {
            // No changes - very high savings
            result.condensed_html = Some(format!(
                "[No HTML changes from previous round]\nURL: {}\nTitle: {}",
                url, title
            ));
            result.savings_ratio = 0.95;
        }

        result.full_html = Some(html.to_string());
        result
    }

    /// Compute hashes for significant elements in HTML.
    fn compute_element_hashes(html: &str) -> HashMap<String, u64> {
        let mut hashes = HashMap::new();

        // Use a simple approach: hash content by tag type and position
        // This is a simplified version - a full implementation would parse the DOM

        // Hash major sections
        for tag in &[
            "body", "main", "article", "section", "nav", "header", "footer", "form",
        ] {
            if let Some((start, end)) = Self::find_tag_bounds(html, tag) {
                let content = &html[start..end];
                let hash = fnv1a64(content.as_bytes());
                hashes.insert(tag.to_string(), hash);
            }
        }

        // Hash individual elements by index within their type
        for (tag, prefix) in &[
            ("input", "input"),
            ("button", "btn"),
            ("a", "link"),
            ("div", "div"),
            ("p", "para"),
        ] {
            let mut idx = 0;
            let mut search_start = 0;
            while let Some(pos) = html[search_start..].find(&format!("<{}", tag)) {
                let abs_pos = search_start + pos;
                if let Some(end) = html[abs_pos..].find('>') {
                    let tag_content = &html[abs_pos..abs_pos + end + 1];
                    let hash = fnv1a64(tag_content.as_bytes());
                    hashes.insert(format!("{}_{}", prefix, idx), hash);
                    idx += 1;
                    search_start = abs_pos + end + 1;
                } else {
                    break;
                }
                if idx > 100 {
                    break; // Limit to prevent huge maps
                }
            }
        }

        hashes
    }

    /// Find the bounds of a tag in HTML.
    fn find_tag_bounds(html: &str, tag: &str) -> Option<(usize, usize)> {
        let open = format!("<{}", tag);
        let close = format!("</{}>", tag);

        let start = html.find(&open)?;
        let end = html.rfind(&close).map(|i| i + close.len())?;

        if end > start {
            Some((start, end))
        } else {
            None
        }
    }

    /// Extract content of an element by path.
    fn extract_element_content(html: &str, path: &str) -> Option<String> {
        // Parse path like "input_0" or "body"
        let parts: Vec<&str> = path.split('_').collect();
        let tag = match parts.first() {
            Some(&"input") => "input",
            Some(&"btn") => "button",
            Some(&"link") => "a",
            Some(&"div") => "div",
            Some(&"para") => "p",
            Some(t) => *t,
            None => return None,
        };

        let idx = parts.get(1).and_then(|s| s.parse::<usize>().ok());

        // Find the tag at the specified index
        let mut current_idx = 0;
        let mut search_start = 0;
        let open_tag = format!("<{}", tag);

        while let Some(pos) = html[search_start..].find(&open_tag) {
            let abs_pos = search_start + pos;
            if idx.is_none_or(|i| i == current_idx) {
                // Find the end of this element
                if let Some(end_pos) = html[abs_pos..].find(&format!("</{}>", tag)) {
                    let content = &html[abs_pos..abs_pos + end_pos + tag.len() + 3];
                    // Truncate if too long
                    if content.len() > 500 {
                        return Some(format!("{}...[truncated]", &content[..500]));
                    }
                    return Some(content.to_string());
                } else if let Some(end_pos) = html[abs_pos..].find("/>") {
                    // Self-closing tag
                    let content = &html[abs_pos..abs_pos + end_pos + 2];
                    return Some(content.to_string());
                }
            }
            current_idx += 1;
            search_start = abs_pos + open_tag.len();
        }

        None
    }

    /// Get current round number.
    pub fn round(&self) -> usize {
        self.round
    }

    /// Check if this is the first round.
    pub fn is_initial(&self) -> bool {
        self.round == 0
    }
}

/// Result of computing an HTML diff.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlDiffResult {
    /// Whether this is the initial state (no previous).
    pub is_initial: bool,
    /// Whether URL changed.
    pub url_changed: bool,
    /// Whether title changed.
    pub title_changed: bool,
    /// Elements that changed.
    pub changed: Vec<ElementChange>,
    /// New elements that appeared.
    pub added: Vec<String>,
    /// Paths of elements that were removed.
    pub removed: Vec<String>,
    /// Count of unchanged elements.
    pub unchanged_count: usize,
    /// Condensed HTML context for LLM.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub condensed_html: Option<String>,
    /// Full HTML (for reference/debugging).
    #[serde(skip)]
    pub full_html: Option<String>,
    /// Token savings ratio (0.0 to 1.0).
    pub savings_ratio: f64,
}

impl HtmlDiffResult {
    /// Create an initial (first round) result.
    pub fn initial(html: &str, _url: &str, _title: &str) -> Self {
        Self {
            is_initial: true,
            url_changed: true,
            title_changed: true,
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 0,
            condensed_html: Some(html.to_string()),
            full_html: Some(html.to_string()),
            savings_ratio: 0.0,
        }
    }

    /// Check if there are any significant changes.
    pub fn has_changes(&self) -> bool {
        self.url_changed
            || self.title_changed
            || !self.changed.is_empty()
            || !self.added.is_empty()
            || !self.removed.is_empty()
    }

    /// Get the HTML to send to the LLM.
    ///
    /// Returns condensed HTML if available and there are savings,
    /// otherwise returns full HTML.
    pub fn html_for_llm(&self, min_savings: f64) -> &str {
        if self.is_initial || self.savings_ratio < min_savings {
            self.full_html.as_deref().unwrap_or("")
        } else {
            self.condensed_html.as_deref().unwrap_or("")
        }
    }

    /// Build a condensed context string for the LLM.
    fn build_condensed_context(&self, url: &str, title: &str) -> String {
        let mut out = String::with_capacity(2048);

        out.push_str("[HTML DIFF - Changes from previous round]\n\n");

        if self.url_changed {
            out.push_str("URL CHANGED: ");
            out.push_str(url);
            out.push('\n');
        }

        if self.title_changed {
            out.push_str("TITLE CHANGED: ");
            out.push_str(title);
            out.push('\n');
        }

        if !self.changed.is_empty() {
            out.push_str("\nCHANGED ELEMENTS:\n");
            for change in &self.changed {
                out.push_str(&format!("- {} ({:?}):\n", change.path, change.change_type));
                if let Some(content) = &change.content {
                    out.push_str(content);
                    out.push('\n');
                }
            }
        }

        if !self.added.is_empty() {
            out.push_str("\nNEW ELEMENTS:\n");
            for elem in &self.added {
                out.push_str("+ ");
                out.push_str(elem);
                out.push('\n');
            }
        }

        if !self.removed.is_empty() {
            out.push_str("\nREMOVED ELEMENTS:\n");
            for path in &self.removed {
                out.push_str("- ");
                out.push_str(path);
                out.push('\n');
            }
        }

        if self.unchanged_count > 0 {
            out.push_str(&format!(
                "\n[{} elements unchanged]\n",
                self.unchanged_count
            ));
        }

        out
    }

    /// Estimate token savings.
    pub fn estimated_token_savings(&self, original_tokens: usize) -> usize {
        (original_tokens as f64 * self.savings_ratio) as usize
    }
}

/// A single element change.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementChange {
    /// Path/identifier of the element.
    pub path: String,
    /// Type of change.
    pub change_type: ChangeType,
    /// New content (if applicable).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
}

/// Type of change to an element.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeType {
    /// Element content/text changed.
    ContentChanged,
    /// Element attributes changed.
    AttributeChanged,
    /// Element appeared (new).
    Appeared,
    /// Element disappeared (removed).
    Disappeared,
    /// Element moved position.
    Moved,
}

/// Statistics about HTML diff performance.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiffStats {
    /// Total rounds processed.
    pub rounds: usize,
    /// Total original bytes.
    pub total_original_bytes: usize,
    /// Total condensed bytes.
    pub total_condensed_bytes: usize,
    /// Average savings ratio.
    pub average_savings: f64,
    /// Rounds with significant changes.
    pub rounds_with_changes: usize,
}

impl DiffStats {
    /// Create new stats tracker.
    pub fn new() -> Self {
        Self::default()
    }

    /// Record a diff result.
    pub fn record(&mut self, result: &HtmlDiffResult, original_bytes: usize) {
        self.rounds += 1;
        self.total_original_bytes += original_bytes;

        let condensed_bytes = result
            .condensed_html
            .as_ref()
            .map(|s| s.len())
            .unwrap_or(original_bytes);
        self.total_condensed_bytes += condensed_bytes;

        self.average_savings =
            1.0 - (self.total_condensed_bytes as f64 / self.total_original_bytes as f64);

        if result.has_changes() {
            self.rounds_with_changes += 1;
        }
    }

    /// Get overall bytes saved.
    pub fn bytes_saved(&self) -> usize {
        self.total_original_bytes
            .saturating_sub(self.total_condensed_bytes)
    }
}

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

    #[test]
    fn test_diff_mode() {
        assert!(!HtmlDiffMode::Disabled.should_diff(50_000, 1));
        assert!(HtmlDiffMode::Enabled.should_diff(1000, 1));
        assert!(!HtmlDiffMode::Enabled.should_diff(1000, 0));
        assert!(HtmlDiffMode::Auto.should_diff(50_000, 1));
        assert!(!HtmlDiffMode::Auto.should_diff(5_000, 1));
    }

    #[test]
    fn test_initial_state() {
        let mut tracker = PageStateDiff::new();
        assert!(tracker.is_initial());
        assert_eq!(tracker.round(), 0);

        let html = "<html><body><p>Hello</p></body></html>";
        let result = tracker.update(html, "https://example.com", "Test");

        assert!(result.is_initial);
        assert!(!tracker.is_initial());
        assert_eq!(tracker.round(), 1);
    }

    #[test]
    fn test_no_changes() {
        let mut tracker = PageStateDiff::new();
        let html = "<html><body><p>Hello</p></body></html>";

        // First update
        tracker.update(html, "https://example.com", "Test");

        // Second update with same content
        let result = tracker.update(html, "https://example.com", "Test");

        assert!(!result.is_initial);
        assert!(!result.url_changed);
        assert!(!result.title_changed);
        assert!(result.savings_ratio > 0.5);
    }

    #[test]
    fn test_url_change_detected() {
        let mut tracker = PageStateDiff::new();
        let html = "<html><body><p>Hello</p></body></html>";

        tracker.update(html, "https://example.com/page1", "Test");
        let result = tracker.update(html, "https://example.com/page2", "Test");

        assert!(result.url_changed);
        assert!(!result.title_changed);
    }

    #[test]
    fn test_content_change_detected() {
        let mut tracker = PageStateDiff::new();

        let html1 = "<html><body><p>Hello</p></body></html>";
        let html2 = "<html><body><p>World</p></body></html>";

        tracker.update(html1, "https://example.com", "Test");
        let result = tracker.update(html2, "https://example.com", "Test");

        assert!(result.has_changes());
        // Body content changed
        assert!(!result.changed.is_empty() || result.unchanged_count > 0);
    }

    #[test]
    fn test_element_hashing() {
        let html = r#"
            <html>
            <body>
                <input type="text" value="hello">
                <button>Click me</button>
            </body>
            </html>
        "#;

        let hashes = PageStateDiff::compute_element_hashes(html);
        assert!(hashes.contains_key("body"));
        assert!(hashes.contains_key("input_0"));
        assert!(hashes.contains_key("btn_0"));
    }

    #[test]
    fn test_savings_calculation() {
        let result = HtmlDiffResult {
            is_initial: false,
            url_changed: false,
            title_changed: false,
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 10,
            condensed_html: Some("[No changes]".to_string()),
            full_html: Some("x".repeat(1000)),
            savings_ratio: 0.9,
        };

        assert_eq!(result.estimated_token_savings(1000), 900);
    }

    #[test]
    fn test_diff_stats() {
        let mut stats = DiffStats::new();

        let result1 = HtmlDiffResult {
            is_initial: true,
            url_changed: true,
            title_changed: true,
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 0,
            condensed_html: Some("a".repeat(1000)),
            full_html: None,
            savings_ratio: 0.0,
        };

        stats.record(&result1, 1000);
        assert_eq!(stats.rounds, 1);
        assert_eq!(stats.total_original_bytes, 1000);

        let result2 = HtmlDiffResult {
            is_initial: false,
            url_changed: false,
            title_changed: false,
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 5,
            condensed_html: Some("a".repeat(100)),
            full_html: None,
            savings_ratio: 0.9,
        };

        stats.record(&result2, 1000);
        assert_eq!(stats.rounds, 2);
        assert!(stats.average_savings > 0.0);
    }

    #[test]
    fn test_html_for_llm() {
        let result = HtmlDiffResult {
            is_initial: false,
            url_changed: false,
            title_changed: false,
            changed: Vec::new(),
            added: Vec::new(),
            removed: Vec::new(),
            unchanged_count: 10,
            condensed_html: Some("condensed".to_string()),
            full_html: Some("full_content".to_string()),
            savings_ratio: 0.8,
        };

        // With high savings, use condensed
        assert_eq!(result.html_for_llm(0.5), "condensed");

        // With low min_savings threshold exceeded, use condensed
        assert_eq!(result.html_for_llm(0.9), "full_content");
    }

    #[test]
    fn test_reset() {
        let mut tracker = PageStateDiff::new();
        tracker.update("<html></html>", "url", "title");
        assert_eq!(tracker.round(), 1);

        tracker.reset();
        assert_eq!(tracker.round(), 0);
        assert!(tracker.is_initial());
    }
}
