//! Diagnostic types for structured error reporting.
//!
//! Diagnostics are collected during parsing, rendering, and other operations
//! to provide structured, programmatic access to issues encountered in PDF
//! files. This complements the `tracing`-based logging with machine-readable
//! diagnostic values.
//!
//! Diagnostics are always collected regardless of parsing mode (`Strict` or
//! `Lenient`). They enable use cases such as PDF validation tools, pre-flight
//! checks, and quality reports for document processing pipelines.

use std::fmt;

/// Severity of a diagnostic event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
    /// A recoverable issue that may affect output quality.
    Warning,
    /// A non-recoverable issue or a serious problem.
    Error,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Severity::Warning => write!(f, "warning"),
            Severity::Error => write!(f, "error"),
        }
    }
}

/// Category of a diagnostic event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiagCategory {
    /// Issues with PDF syntax or structure.
    Parse,
    /// Issues with font loading or processing.
    Font,
    /// Issues with color space definitions.
    ColorSpace,
    /// Issues with stream data (compression, length, etc.).
    Stream,
    /// Issues during rendering.
    Render,
    /// Issues during text extraction.
    Text,
}

impl fmt::Display for DiagCategory {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            DiagCategory::Parse => write!(f, "parse"),
            DiagCategory::Font => write!(f, "font"),
            DiagCategory::ColorSpace => write!(f, "colorspace"),
            DiagCategory::Stream => write!(f, "stream"),
            DiagCategory::Render => write!(f, "render"),
            DiagCategory::Text => write!(f, "text"),
        }
    }
}

/// Location information for a diagnostic event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagLocation {
    /// Byte offset in the source data where the issue was detected.
    pub offset: u64,
    /// Optional context string describing the location (e.g., "in object 5 0 R").
    pub context: Option<String>,
}

/// A diagnostic event recorded during parsing or rendering.
///
/// Diagnostics provide structured information about issues encountered
/// while processing a PDF file. They are collected regardless of parsing
/// mode and can be inspected programmatically.
#[derive(Debug, Clone)]
pub struct Diagnostic {
    /// The severity of this diagnostic.
    pub severity: Severity,
    /// The category of this diagnostic.
    pub category: DiagCategory,
    /// A human-readable description of the issue.
    pub message: String,
    /// Optional location where the issue was detected.
    pub location: Option<DiagLocation>,
}

impl fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}] {}: {}", self.severity, self.category, self.message)?;
        if let Some(ref loc) = self.location {
            write!(f, " (at offset {})", loc.offset)?;
            if let Some(ref ctx) = loc.context {
                write!(f, " [{ctx}]")?;
            }
        }
        Ok(())
    }
}

/// Collects diagnostics during an operation.
///
/// This type accumulates [`Diagnostic`] values that can be inspected after
/// the operation completes. It is `Send` and `Sync`, but requires `&mut self`
/// for mutation (exclusive access enforced by the borrow checker).
#[derive(Debug, Default)]
pub struct DiagnosticCollector {
    diagnostics: Vec<Diagnostic>,
}

impl DiagnosticCollector {
    /// Create a new empty collector.
    pub fn new() -> Self {
        Self {
            diagnostics: Vec::new(),
        }
    }

    /// Add a diagnostic to the collection.
    pub fn push(&mut self, diagnostic: Diagnostic) {
        self.diagnostics.push(diagnostic);
    }

    /// Return all collected diagnostics.
    pub fn diagnostics(&self) -> &[Diagnostic] {
        &self.diagnostics
    }

    /// Return the number of collected diagnostics.
    pub fn len(&self) -> usize {
        self.diagnostics.len()
    }

    /// Returns `true` if no diagnostics have been collected.
    pub fn is_empty(&self) -> bool {
        self.diagnostics.is_empty()
    }

    /// Return the number of diagnostics with the given severity.
    pub fn count_by_severity(&self, severity: Severity) -> usize {
        self.diagnostics
            .iter()
            .filter(|d| d.severity == severity)
            .count()
    }

    /// Returns `true` if any diagnostic has `Error` severity.
    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|d| d.severity == Severity::Error)
    }
}

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

    #[test]
    fn test_severity_display() {
        assert_eq!(format!("{}", Severity::Warning), "warning");
        assert_eq!(format!("{}", Severity::Error), "error");
    }

    #[test]
    fn test_category_display() {
        assert_eq!(format!("{}", DiagCategory::Parse), "parse");
        assert_eq!(format!("{}", DiagCategory::Font), "font");
        assert_eq!(format!("{}", DiagCategory::Stream), "stream");
    }

    #[test]
    fn test_diagnostic_display_without_location() {
        let diag = Diagnostic {
            severity: Severity::Warning,
            category: DiagCategory::Parse,
            message: "missing endobj".to_string(),
            location: None,
        };
        assert_eq!(format!("{diag}"), "[warning] parse: missing endobj");
    }

    #[test]
    fn test_diagnostic_display_with_location() {
        let diag = Diagnostic {
            severity: Severity::Error,
            category: DiagCategory::Stream,
            message: "decompression failed".to_string(),
            location: Some(DiagLocation {
                offset: 12345,
                context: Some("in object 5 0 R".to_string()),
            }),
        };
        let s = format!("{diag}");
        assert!(s.contains("at offset 12345"));
        assert!(s.contains("in object 5 0 R"));
    }

    #[test]
    fn test_collector_push_and_retrieve() {
        let mut collector = DiagnosticCollector::new();
        assert!(collector.is_empty());

        collector.push(Diagnostic {
            severity: Severity::Warning,
            category: DiagCategory::Parse,
            message: "test warning".to_string(),
            location: None,
        });

        assert_eq!(collector.len(), 1);
        assert!(!collector.is_empty());
        assert_eq!(collector.diagnostics()[0].message, "test warning");
    }

    #[test]
    fn test_collector_count_by_severity() {
        let mut collector = DiagnosticCollector::new();

        collector.push(Diagnostic {
            severity: Severity::Warning,
            category: DiagCategory::Parse,
            message: "w1".to_string(),
            location: None,
        });
        collector.push(Diagnostic {
            severity: Severity::Error,
            category: DiagCategory::Font,
            message: "e1".to_string(),
            location: None,
        });
        collector.push(Diagnostic {
            severity: Severity::Warning,
            category: DiagCategory::Stream,
            message: "w2".to_string(),
            location: None,
        });

        assert_eq!(collector.count_by_severity(Severity::Warning), 2);
        assert_eq!(collector.count_by_severity(Severity::Error), 1);
        assert!(collector.has_errors());
    }

    #[test]
    fn test_collector_no_errors() {
        let mut collector = DiagnosticCollector::new();
        collector.push(Diagnostic {
            severity: Severity::Warning,
            category: DiagCategory::Parse,
            message: "just a warning".to_string(),
            location: None,
        });
        assert!(!collector.has_errors());
    }

    #[test]
    fn test_diagnostic_types_are_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<Severity>();
        assert_send_sync::<DiagCategory>();
        assert_send_sync::<DiagLocation>();
        assert_send_sync::<Diagnostic>();
        assert_send_sync::<DiagnosticCollector>();
    }
}
