//! Incremental transcript file parser.
//!
//! Parses Claude Code JSONL transcript files efficiently by tracking
//! file positions and only reading new content.

use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;

use crate::model::Event;
use crate::model::error::TranscriptError;
use crate::util::truncate_string;

use super::{ToolInfoMap, TranscriptEntry};

/// Tracks file read position for incremental parsing.
#[derive(Debug, Clone, Default)]
pub struct FilePosition {
    /// Byte offset in file
    pub offset: u64,
    /// Last line number processed (1-based, for debugging)
    pub line_number: u64,
    /// Last UUID processed (for validation)
    pub last_uuid: Option<String>,
}

impl FilePosition {
    /// Create a new position at the start of file.
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a position at a specific offset.
    pub fn at_offset(offset: u64) -> Self {
        Self {
            offset,
            line_number: 0,
            last_uuid: None,
        }
    }
}

/// Result of parsing a transcript file.
#[derive(Debug)]
pub struct ParseResult {
    /// Events extracted from the transcript
    pub events: Vec<Event>,
    /// New file position after parsing
    pub position: FilePosition,
    /// UUIDs seen during this parse (for deduplication)
    pub uuids: HashSet<String>,
    /// Number of lines parsed into events
    pub lines_parsed: u64,
    /// Number of lines skipped (non-message entries like file-history-snapshot)
    pub lines_skipped: u64,
    /// Number of lines that failed to parse as JSON
    pub parse_errors: u64,
}

/// Parser for Claude Code transcript JSONL files.
pub struct TranscriptParser {
    machine_id: String,
}

impl TranscriptParser {
    /// Create a new parser with the given machine ID.
    pub fn new(machine_id: impl Into<String>) -> Self {
        Self {
            machine_id: machine_id.into(),
        }
    }

    /// Parse new entries from a transcript file starting at the given position.
    ///
    /// Returns events, updated position, and UUIDs seen.
    pub fn parse_incremental(
        &self,
        path: &Path,
        start_position: &FilePosition,
    ) -> Result<ParseResult, TranscriptError> {
        let file = File::open(path).map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                TranscriptError::NotFound(path.display().to_string())
            } else {
                TranscriptError::Io(e)
            }
        })?;

        let file_len = file.metadata()?.len();

        // If position is past file end, file may have been truncated - reset
        let start_offset = if start_position.offset > file_len {
            0
        } else {
            start_position.offset
        };

        let mut reader = BufReader::new(file);
        reader.seek(SeekFrom::Start(start_offset))?;

        let mut events = Vec::new();
        let mut uuids = HashSet::new();
        let mut current_offset = start_offset;
        let mut line_number = start_position.line_number;
        let mut last_uuid = start_position.last_uuid.clone();
        let mut lines_parsed = 0u64;
        let mut lines_skipped = 0u64;
        let mut parse_errors = 0u64;

        // Track tool info across entries to enrich PostToolUse events
        let mut tool_info_map: ToolInfoMap = Default::default();

        let mut line = String::new();
        loop {
            line.clear();
            let bytes_read = reader.read_line(&mut line)?;
            if bytes_read == 0 {
                break; // EOF
            }

            let line_bytes = bytes_read as u64;
            line_number += 1;

            // Skip empty lines
            let trimmed = line.trim();
            if trimmed.is_empty() {
                current_offset += line_bytes;
                continue;
            }

            // Try to parse as TranscriptEntry
            match serde_json::from_str::<TranscriptEntry>(trimmed) {
                Ok(entry) => {
                    // Track UUID for deduplication
                    uuids.insert(entry.uuid.clone());
                    last_uuid = Some(entry.uuid.clone());

                    // Skip non-message entries
                    if entry.should_skip() {
                        lines_skipped += 1;
                    } else {
                        // Convert to events, passing tool_info_map for cross-entry tracking
                        let entry_events = entry.into_events(&self.machine_id, &mut tool_info_map);
                        events.extend(entry_events);
                        lines_parsed += 1;
                    }
                }
                Err(_) => {
                    // Track parse errors separately from intentionally skipped entries
                    // This helps identify format changes or corruption vs expected non-message entries
                    parse_errors += 1;
                }
            }

            current_offset += line_bytes;
        }

        let new_position = FilePosition {
            offset: current_offset,
            line_number,
            last_uuid,
        };

        Ok(ParseResult {
            events,
            position: new_position,
            uuids,
            lines_parsed,
            lines_skipped,
            parse_errors,
        })
    }

    /// Parse entire transcript file from the beginning.
    pub fn parse_full(&self, path: &Path) -> Result<ParseResult, TranscriptError> {
        self.parse_incremental(path, &FilePosition::new())
    }
}

/// Extract the first user prompt from a transcript file.
///
/// This is a lightweight operation that reads only until it finds the first
/// user message with text content. Used as a fallback when the UserPromptSubmit
/// hook doesn't fire (e.g., when Claude is started with an inline prompt like
/// `claude 'initial prompt'`).
///
/// Returns `None` if:
/// - The file doesn't exist or can't be read
/// - No user message with text content is found
/// - The first user message is a tool result (no prompt text)
pub fn extract_first_prompt(path: &Path) -> Option<String> {
    let file = File::open(path).ok()?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = line.ok()?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        // Try to parse as TranscriptEntry
        if let Ok(entry) = serde_json::from_str::<TranscriptEntry>(trimmed) {
            // Only look at non-sidechain user entries
            if entry.entry_type == "user" && !entry.is_sidechain {
                // Extract prompt text if available
                if let Some(ref message) = entry.message
                    && let Some(ref content) = message.content
                    && let Some(text) = content.text()
                    && !text.is_empty()
                {
                    return Some(truncate_string(text, 1000));
                }
            }
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn create_test_file(content: &str) -> NamedTempFile {
        let mut file = NamedTempFile::new().unwrap();
        file.write_all(content.as_bytes()).unwrap();
        file.flush().unwrap();
        file
    }

    #[test]
    fn test_parse_empty_file() {
        let file = create_test_file("");
        let parser = TranscriptParser::new("machine-1");
        let result = parser.parse_full(file.path()).unwrap();

        assert!(result.events.is_empty());
        assert_eq!(result.lines_parsed, 0);
    }

    #[test]
    fn test_parse_single_entry() {
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}"#;

        let file = create_test_file(content);
        let parser = TranscriptParser::new("machine-1");
        let result = parser.parse_full(file.path()).unwrap();

        assert_eq!(result.events.len(), 1);
        assert_eq!(result.lines_parsed, 1);
        assert!(result.uuids.contains("1"));
    }

    #[test]
    fn test_incremental_parsing() {
        let line1 = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}"#;
        let line2 = r#"{"sessionId":"test","uuid":"2","isSidechain":false,"timestamp":"2025-01-01T00:00:01Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":200,"output_tokens":100}}}"#;

        let content = format!("{}\n{}\n", line1, line2);
        let file = create_test_file(&content);
        let parser = TranscriptParser::new("machine-1");

        // First parse - get first line
        let result1 = parser.parse_full(file.path()).unwrap();
        assert_eq!(result1.events.len(), 2);

        // Append new content
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(file.path())
            .unwrap();
        let line3 = r#"{"sessionId":"test","uuid":"3","isSidechain":false,"timestamp":"2025-01-01T00:00:02Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":300,"output_tokens":150}}}"#;
        writeln!(f, "{}", line3).unwrap();

        // Incremental parse from saved position
        let result2 = parser
            .parse_incremental(file.path(), &result1.position)
            .unwrap();
        assert_eq!(result2.events.len(), 1);
        assert!(result2.uuids.contains("3"));
    }

    #[test]
    fn test_skip_non_message_entries() {
        let content = r#"{"type":"file-history-snapshot","sessionId":"test","uuid":"snap1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z"}
{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}"#;

        let file = create_test_file(content);
        let parser = TranscriptParser::new("machine-1");
        let result = parser.parse_full(file.path()).unwrap();

        assert_eq!(result.events.len(), 1);
        assert_eq!(result.lines_parsed, 1);
        assert_eq!(result.lines_skipped, 1);
    }

    #[test]
    fn test_handle_truncated_file() {
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}"#;

        let file = create_test_file(content);
        let parser = TranscriptParser::new("machine-1");

        // Create a position past the file end
        let old_position = FilePosition {
            offset: 10000,
            line_number: 100,
            last_uuid: Some("old".to_string()),
        };

        // Should reset to beginning
        let result = parser
            .parse_incremental(file.path(), &old_position)
            .unwrap();
        assert_eq!(result.events.len(), 1);
    }

    #[test]
    fn test_file_not_found() {
        let parser = TranscriptParser::new("machine-1");
        let result = parser.parse_full(Path::new("/nonexistent/path.jsonl"));

        assert!(matches!(result, Err(TranscriptError::NotFound(_))));
    }

    #[test]
    fn test_extract_first_prompt_basic() {
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"user","message":{"role":"user","content":"hello world"}}"#;
        let file = create_test_file(content);

        let result = extract_first_prompt(file.path());
        assert_eq!(result, Some("hello world".to_string()));
    }

    #[test]
    fn test_extract_first_prompt_with_array_content() {
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"user","message":{"role":"user","content":[{"type":"text","text":"hello from array"}]}}"#;
        let file = create_test_file(content);

        let result = extract_first_prompt(file.path());
        assert_eq!(result, Some("hello from array".to_string()));
    }

    #[test]
    fn test_extract_first_prompt_skips_sidechain() {
        // First entry is sidechain, second is main chain
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":true,"timestamp":"2025-01-01T00:00:00Z","type":"user","message":{"role":"user","content":"sidechain prompt"}}
{"sessionId":"test","uuid":"2","isSidechain":false,"timestamp":"2025-01-01T00:00:01Z","type":"user","message":{"role":"user","content":"main chain prompt"}}"#;
        let file = create_test_file(content);

        let result = extract_first_prompt(file.path());
        assert_eq!(result, Some("main chain prompt".to_string()));
    }

    #[test]
    fn test_extract_first_prompt_skips_tool_results() {
        // First entry is tool result (no text), second is user prompt
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"result"}]}}
{"sessionId":"test","uuid":"2","isSidechain":false,"timestamp":"2025-01-01T00:00:01Z","type":"user","message":{"role":"user","content":"actual prompt"}}"#;
        let file = create_test_file(content);

        let result = extract_first_prompt(file.path());
        assert_eq!(result, Some("actual prompt".to_string()));
    }

    #[test]
    fn test_extract_first_prompt_empty_file() {
        let file = create_test_file("");
        let result = extract_first_prompt(file.path());
        assert_eq!(result, None);
    }

    #[test]
    fn test_extract_first_prompt_no_user_messages() {
        let content = r#"{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}"#;
        let file = create_test_file(content);

        let result = extract_first_prompt(file.path());
        assert_eq!(result, None);
    }

    #[test]
    fn test_extract_first_prompt_truncates_long_prompt() {
        let long_text = "a".repeat(1500);
        let content = format!(
            r#"{{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"user","message":{{"role":"user","content":"{}"}}}}"#,
            long_text
        );
        let file = create_test_file(&content);

        let result = extract_first_prompt(file.path());
        assert!(result.is_some());
        let prompt = result.unwrap();
        assert!(prompt.ends_with("..."));
        assert!(prompt.len() <= 1003); // 1000 + "..."
    }

    #[test]
    fn test_extract_first_prompt_file_not_found() {
        let result = extract_first_prompt(Path::new("/nonexistent/file.jsonl"));
        assert_eq!(result, None);
    }

    #[test]
    fn test_parse_errors_tracked_separately() {
        // Mix of valid entries, skipped entries, and unparseable lines
        let content = r#"{"type":"file-history-snapshot","sessionId":"test","uuid":"snap1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z"}
not valid json at all
{"sessionId":"test","uuid":"1","isSidechain":false,"timestamp":"2025-01-01T00:00:00Z","type":"assistant","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":50}}}
{incomplete json
"#;

        let file = create_test_file(content);
        let parser = TranscriptParser::new("machine-1");
        let result = parser.parse_full(file.path()).unwrap();

        assert_eq!(result.events.len(), 1); // Only the valid assistant entry
        assert_eq!(result.lines_parsed, 1); // One valid message entry
        assert_eq!(result.lines_skipped, 1); // file-history-snapshot
        assert_eq!(result.parse_errors, 2); // Two unparseable lines
    }
}
