//! PDF/UA-1 conformance checking (ISO 14289-1).
//!
//! Validates PDF documents against PDF/UA-1 requirements
//! for universal accessibility.

use crate::check;
use crate::tagged;
use crate::ComplianceReport;
use pdf_syntax::object::dict::keys;
use pdf_syntax::Pdf;

/// Validate a PDF against PDF/UA-1 (ISO 14289-1).
pub fn validate(pdf: &Pdf) -> ComplianceReport {
    let mut report = ComplianceReport::default();

    check_pdfua_identifier(pdf, &mut report);
    check_document_title(pdf, &mut report);
    check_document_language(pdf, &mut report);
    check_marked_content(pdf, &mut report);
    check_structure_tree(pdf, &mut report);
    check_tab_order(pdf, &mut report);
    check_font_glyph_mapping(pdf, &mut report);

    report.compliant = report.is_compliant();
    report
}

/// Check that XMP metadata contains pdfuaid:part=1.
fn check_pdfua_identifier(pdf: &Pdf, report: &mut ComplianceReport) {
    let Some(xmp) = check::get_xmp_metadata(pdf) else {
        check::error(
            report,
            "7.1",
            "No XMP metadata stream; PDF/UA requires pdfuaid:part identifier",
        );
        return;
    };

    let Some(part) = check::parse_xmp_pdfua(&xmp) else {
        check::error(
            report,
            "7.1",
            "XMP metadata missing pdfuaid:part identifier",
        );
        return;
    };

    if part != 1 {
        check::error(
            report,
            "7.1",
            format!("pdfuaid:part={part}, expected 1 for PDF/UA-1"),
        );
    }
}

/// Check ViewerPreferences/DisplayDocTitle = true.
fn check_document_title(pdf: &Pdf, report: &mut ComplianceReport) {
    if !check::display_doc_title(pdf) {
        check::error(
            report,
            "7.1",
            "ViewerPreferences/DisplayDocTitle is not true; PDF/UA requires it",
        );
    }
}

/// Returns true when the catalog /Outlines tree has at least one visible entry.
///
/// ISO 14289-1 §7.2 requires catalog-level /Lang for outline entries
/// (`gContainsCatalogLang == true` for PDOutline objects).  An /Outlines dict
/// with /Count 0 has no visible bookmark text and does NOT trigger the rule.
/// (#FP-7.2-element-lang, cs-7.2-t02-fail-a)
fn catalog_has_nonempty_outlines(pdf: &Pdf) -> bool {
    use pdf_syntax::object::Dict;
    let Some(cat) = check::catalog(pdf) else {
        return false;
    };
    let Some(outlines) = cat.get::<Dict<'_>>(keys::OUTLINES) else {
        return false;
    };
    // /Count 0 means empty outline tree — no actual bookmark entries to display.
    outlines
        .get::<i32>(b"Count" as &[u8])
        .map(|c| c != 0)
        .unwrap_or(true) // absent /Count -> assume entries exist
}

/// Returns true if any structure element in the StructTreeRoot has a /Lang entry.
///
/// ISO 14289-1 §7.2 allows omitting catalog /Lang when natural language is
/// provided for all text through structure element /Lang entries.  VeraPDF
/// considers a document conformant when every element that requires language
/// carries its own /Lang, even without a catalog-level entry.
/// This exemption does NOT apply when the document has non-empty /Outlines.
/// (#FP-7.2-element-lang)
fn struct_has_any_lang(pdf: &Pdf) -> bool {
    use pdf_syntax::object::{Array, Dict};

    fn scan(elem: &Dict<'_>, depth: usize) -> bool {
        if depth > 200 {
            return false;
        }
        // /Lang present on this element?
        if elem.get::<pdf_syntax::object::String>(keys::LANG).is_some() {
            return true;
        }
        // Recurse into kids.
        if let Some(kids) = elem.get::<Array<'_>>(keys::K) {
            for kid in kids.iter::<Dict<'_>>() {
                if scan(&kid, depth + 1) {
                    return true;
                }
            }
        } else if let Some(kid) = elem.get::<Dict<'_>>(keys::K) {
            return scan(&kid, depth + 1);
        }
        false
    }

    let Some(root) = check::struct_tree_root(pdf) else {
        return false;
    };
    if let Some(kids) = root.get::<Array<'_>>(keys::K) {
        for kid in kids.iter::<Dict<'_>>() {
            if scan(&kid, 0) {
                return true;
            }
        }
    } else if let Some(kid) = root.get::<Dict<'_>>(keys::K) {
        return scan(&kid, 0);
    }
    false
}

/// Check that the document has a /Lang entry on the catalog.
///
/// ISO 14289-1 §7.2 allows element-level /Lang as an alternative to catalog
/// /Lang when all text has language specified via structure elements — but ONLY
/// when the document has no non-empty /Outlines.  Bookmark entries require
/// `gContainsCatalogLang == true`, so catalog /Lang is needed whenever visible
/// bookmarks are present.
///
/// Error when catalog /Lang is absent AND EITHER:
/// (a) the document has a non-empty /Outlines tree (bookmarks always need it), OR
/// (b) no structure element carries /Lang (no language determined at all).
fn check_document_language(pdf: &Pdf, report: &mut ComplianceReport) {
    if check::document_lang(pdf).is_some() {
        return; // Catalog /Lang present — no error.
    }
    // No catalog /Lang.  Bookmark entries inherit language from the catalog, so
    // if there are visible bookmarks we must always require catalog /Lang.
    if catalog_has_nonempty_outlines(pdf) {
        check::error(
            report,
            "7.2",
            "Document catalog missing /Lang entry; required when /Outlines entries are present",
        );
        return;
    }
    // No outlines: element-level /Lang in the structure tree is sufficient.
    if !struct_has_any_lang(pdf) {
        check::error(
            report,
            "7.2",
            "Document catalog missing /Lang entry; PDF/UA requires document language",
        );
    }
}

/// Check that the document is marked (MarkInfo/Marked = true).
fn check_marked_content(pdf: &Pdf, report: &mut ComplianceReport) {
    if !check::is_marked(pdf) {
        check::error(
            report,
            "7.1",
            "Document is not marked (MarkInfo/Marked missing or false)",
        );
    }
}

/// Check structure tree completeness and quality.
fn check_structure_tree(pdf: &Pdf, report: &mut ComplianceReport) {
    let Some(tree) = tagged::parse(pdf) else {
        check::error(
            report,
            "7.1",
            "No structure tree found; PDF/UA requires all content to be tagged",
        );
        return;
    };

    if tree.root_elements.is_empty() {
        check::error(report, "7.1", "Structure tree is empty; no tagged content");
        return;
    }

    // Check heading hierarchy
    let hierarchy_issues = tree.heading_hierarchy_issues();
    for issue in &hierarchy_issues {
        check::error(report, "7.4.2", issue.clone());
    }

    // Check Figure alt text
    let missing_alt = tree.figures_without_alt();
    for fig in &missing_alt {
        check::error(
            report,
            "7.3",
            format!("Figure element '{}' missing /Alt text", fig.struct_type),
        );
    }

    // Check table headers
    check_table_headers(&tree, report);
}

/// Check that tables have proper TH elements with Scope.
fn check_table_headers(tree: &tagged::StructureTree, report: &mut ComplianceReport) {
    for table in tree.tables() {
        let has_th = table
            .reading_order()
            .iter()
            .any(|e| e.standard_type == "TH");

        if !has_th {
            check::warning(
                report,
                "7.5",
                "Table element found without TH (table header) cells",
            );
        }
    }
}

/// Check that each page with annotations has /Tabs = /S.
///
/// ISO 14289-1 §7.1 requires /Tabs /S only for pages that have annotations
/// (including form fields).  Pages without /Annots need no tab order.
/// VeraPDF checks: "Tab order of page with annotations is set to structure".
/// (#FP-7.1-tabs-s-no-annots)
fn check_tab_order(pdf: &Pdf, report: &mut ComplianceReport) {
    use pdf_syntax::object::Array;
    for (page_idx, page) in pdf.pages().iter().enumerate() {
        let page_dict = page.raw();
        // Skip pages that have no annotations — /Tabs /S is only required
        // when annotations (including form widgets) are present.
        if page_dict.get::<Array<'_>>(keys::ANNOTS).is_none() {
            continue;
        }
        if !check::page_has_tab_order_s(page_dict) {
            check::error_at(
                report,
                "7.1",
                "Page missing /Tabs /S; PDF/UA requires tab order follows structure",
                format!("page {}", page_idx + 1),
            );
        }
    }
}

/// Check that fonts have ToUnicode CMaps for glyph mapping.
fn check_font_glyph_mapping(pdf: &Pdf, report: &mut ComplianceReport) {
    check::for_each_font(pdf, |name, font_dict, page_idx| {
        // Symbolic fonts and Type3 fonts don't strictly need ToUnicode,
        // but non-symbolic fonts should have it for PDF/UA.
        let subtype = font_dict.get::<pdf_syntax::object::Name>(keys::SUBTYPE);
        let is_type3 = subtype.as_ref().is_some_and(|s| s.as_ref() == keys::TYPE3);

        if !is_type3 && !check::font_has_tounicode(font_dict) {
            // Check if it's a standard 14 font (they have implicit encoding)
            let base_font = font_dict
                .get::<pdf_syntax::object::Name>(keys::BASE_FONT)
                .map(|n| std::str::from_utf8(n.as_ref()).unwrap_or("").to_string());

            if !is_standard_14(&base_font) {
                check::warning(
                    report,
                    "7.21.3.1",
                    format!(
                        "Font {name} on page {} missing ToUnicode CMap",
                        page_idx + 1
                    ),
                );
            }
        }
    });
}

/// Check if a font name is one of the PDF standard 14 fonts.
fn is_standard_14(name: &Option<String>) -> bool {
    let Some(name) = name else { return false };
    matches!(
        name.as_str(),
        "Courier"
            | "Courier-Bold"
            | "Courier-Oblique"
            | "Courier-BoldOblique"
            | "Helvetica"
            | "Helvetica-Bold"
            | "Helvetica-Oblique"
            | "Helvetica-BoldOblique"
            | "Times-Roman"
            | "Times-Bold"
            | "Times-Italic"
            | "Times-BoldItalic"
            | "Symbol"
            | "ZapfDingbats"
    )
}

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

    #[test]
    fn standard_14_detection() {
        assert!(is_standard_14(&Some("Helvetica".to_string())));
        assert!(is_standard_14(&Some("Times-Roman".to_string())));
        assert!(!is_standard_14(&Some("ArialMT".to_string())));
        assert!(!is_standard_14(&None));
    }

    #[test]
    fn xmp_pdfua_parsing() {
        let xmp = br#"<rdf:Description pdfuaid:part="1"/>"#;
        let part = check::parse_xmp_pdfua(xmp).unwrap();
        assert_eq!(part, 1);
    }

    #[test]
    fn xmp_pdfua_element() {
        let xmp = br#"<pdfuaid:part>1</pdfuaid:part>"#;
        let part = check::parse_xmp_pdfua(xmp).unwrap();
        assert_eq!(part, 1);
    }

    #[test]
    fn empty_pdf_fails_pdfua() {
        let data = b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
          2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n\
          xref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n\
          trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n109\n%%EOF"
            .to_vec();
        if let Ok(pdf) = Pdf::new(data) {
            let report = validate(&pdf);
            assert!(!report.is_compliant());
            assert!(report.error_count() > 0);
        }
    }
}
