//! Integration tests for `kebab_parse_image::ImageExtractor` (P6-1).

mod common;

use kebab_core::{Block, Extractor, ImageType, ProvenanceKind, SourceSpan};
use kebab_parse_image::ImageExtractor;
use serde_json::Value;

use crate::common::{
    corrupt_png, exif_gps_no_ref_jpg, exif_gps_out_of_range_jpg, exif_with_gps_jpg, fixture_for,
    no_exif_png, red_100x50_png, strip_dynamic_at,
};

fn extract_block(doc: &kebab_core::CanonicalDocument) -> &kebab_core::ImageRefBlock {
    assert_eq!(doc.blocks.len(), 1, "exactly one block expected");
    match &doc.blocks[0] {
        Block::ImageRef(b) => b,
        other => panic!("expected ImageRef, got {other:?}"),
    }
}

#[test]
fn png_decode_produces_correct_dimensions() {
    let bytes = red_100x50_png();
    let fx = fixture_for("photos/red-100x50.png", ImageType::Png, &bytes);
    let doc = ImageExtractor::new()
        .extract(&fx.ctx(), &bytes)
        .expect("PNG extraction must succeed");

    assert_eq!(doc.title, "red-100x50");
    assert_eq!(doc.lang.0, "und");
    assert_eq!(doc.parser_version.0, kebab_parse_image::PARSER_VERSION);

    let dims = doc
        .metadata
        .user
        .get("dimensions")
        .expect("dimensions key present");
    let obj = dims.as_object().expect("dimensions is an object");
    assert_eq!(obj.get("w"), Some(&Value::Number(100.into())));
    assert_eq!(obj.get("h"), Some(&Value::Number(50.into())));
    assert_eq!(obj.get("format"), Some(&Value::String("png".into())));

    let block = extract_block(&doc);
    assert_eq!(block.alt, "red-100x50.png");
    assert_eq!(block.src, "photos/red-100x50.png");
    assert_eq!(block.asset_id, Some(fx.asset.asset_id.clone()));
    assert!(block.ocr.is_none());
    assert!(block.caption.is_none());
    match &block.common.source_span {
        SourceSpan::Region { x, y, w, h } => {
            assert_eq!((*x, *y, *w, *h), (0, 0, 100, 50));
        }
        other => panic!("expected Region span, got {other:?}"),
    }
}

#[test]
fn jpeg_with_exif_gps_captures_whitelisted_tags() {
    let bytes = exif_with_gps_jpg();
    let fx = fixture_for("img/seoul.jpg", ImageType::Jpeg, &bytes);
    let doc = ImageExtractor::new()
        .extract(&fx.ctx(), &bytes)
        .expect("JPEG extraction must succeed");

    let exif = doc
        .metadata
        .user
        .get("exif")
        .and_then(|v| v.as_object())
        .expect("exif object present");
    assert_eq!(exif.get("make"), Some(&Value::String("KebabCam".into())));
    assert_eq!(exif.get("model"), Some(&Value::String("X1".into())));
    assert_eq!(
        exif.get("software"),
        Some(&Value::String("kebab-test".into()))
    );
    assert_eq!(
        exif.get("date_time_original"),
        Some(&Value::String("2024-08-15T12:34:56".into()))
    );
    assert_eq!(exif.get("orientation"), Some(&Value::Number(1.into())));
    let lat = exif
        .get("gps_lat")
        .and_then(serde_json::Value::as_f64)
        .expect("gps_lat");
    let lon = exif
        .get("gps_lon")
        .and_then(serde_json::Value::as_f64)
        .expect("gps_lon");
    assert!((lat - 37.5).abs() < 1e-6, "lat={lat}");
    assert!((lon - 127.0).abs() < 1e-6, "lon={lon}");

    // Maker notes / thumbnails / unrelated tags must NOT have leaked in.
    let allowed: std::collections::HashSet<&str> = [
        "make",
        "model",
        "software",
        "date_time_original",
        "orientation",
        "gps_lat",
        "gps_lon",
    ]
    .into_iter()
    .collect();
    for k in exif.keys() {
        assert!(
            allowed.contains(k.as_str()),
            "non-whitelisted EXIF key leaked: {k}"
        );
    }
}

#[test]
fn no_exif_image_yields_empty_exif_map() {
    let bytes = no_exif_png();
    let fx = fixture_for("img/blank.png", ImageType::Png, &bytes);
    let doc = ImageExtractor::new()
        .extract(&fx.ctx(), &bytes)
        .expect("PNG extraction must succeed");
    let exif = doc
        .metadata
        .user
        .get("exif")
        .and_then(|v| v.as_object())
        .expect("exif object present");
    assert!(
        exif.is_empty(),
        "no-EXIF PNG must yield empty exif map: {exif:?}"
    );
}

#[test]
fn corrupt_image_emits_warning_no_panic() {
    let bytes = corrupt_png();
    let fx = fixture_for("img/corrupt.png", ImageType::Png, &bytes);
    let doc = ImageExtractor::new()
        .extract(&fx.ctx(), &bytes)
        .expect("corrupt PNG must NOT cause an Err — warning provenance event instead");

    // dimensions = null
    assert_eq!(
        doc.metadata.user.get("dimensions"),
        Some(&Value::Null),
        "corrupt image must record dimensions = null"
    );
    // exif = {}
    let exif = doc
        .metadata
        .user
        .get("exif")
        .and_then(|v| v.as_object())
        .expect("exif object present");
    assert!(exif.is_empty());
    // Span is Region(0,0,0,0).
    let block = extract_block(&doc);
    assert!(matches!(
        block.common.source_span,
        SourceSpan::Region {
            x: 0,
            y: 0,
            w: 0,
            h: 0
        }
    ));
    // Warning provenance event.
    let warnings: Vec<_> = doc
        .provenance
        .events
        .iter()
        .filter(|e| e.kind == ProvenanceKind::Warning)
        .collect();
    assert_eq!(warnings.len(), 1, "expected exactly one Warning event");
    assert_eq!(warnings[0].agent, "kb-parse-image");
}

#[test]
fn unsupported_bytes_return_err() {
    let bytes = b"not an image at all".to_vec();
    let fx = fixture_for("img/garbage.png", ImageType::Png, &bytes);
    let r = ImageExtractor::new().extract(&fx.ctx(), &bytes);
    assert!(
        r.is_err(),
        "unrecognised format must propagate Err so caller skips"
    );
}

#[test]
fn provenance_events_are_in_order() {
    let bytes = red_100x50_png();
    let fx = fixture_for("a/b.png", ImageType::Png, &bytes);
    let doc = ImageExtractor::new().extract(&fx.ctx(), &bytes).unwrap();
    let kinds: Vec<_> = doc.provenance.events.iter().map(|e| e.kind).collect();
    assert_eq!(
        kinds,
        vec![ProvenanceKind::Discovered, ProvenanceKind::Parsed]
    );
    assert_eq!(doc.provenance.events[0].agent, "kb-source-fs");
    assert_eq!(doc.provenance.events[0].at, fx.asset.discovered_at);
    assert_eq!(doc.provenance.events[1].agent, "kb-parse-image");
}

#[test]
fn determinism_identical_bytes_produce_identical_ids() {
    let bytes = red_100x50_png();
    let fx_a = fixture_for("a/b.png", ImageType::Png, &bytes);
    let fx_b = fixture_for("a/b.png", ImageType::Png, &bytes);
    let extractor = ImageExtractor::new();
    let doc1 = extractor.extract(&fx_a.ctx(), &bytes).unwrap();
    let doc2 = extractor.extract(&fx_b.ctx(), &bytes).unwrap();
    assert_eq!(doc1.doc_id, doc2.doc_id);
    let id1 = &extract_block(&doc1).common.block_id;
    let id2 = &extract_block(&doc2).common.block_id;
    assert_eq!(id1, id2);
}

#[test]
fn snapshot_red_100x50_canonical_document_stable() {
    let bytes = red_100x50_png();
    let fx = fixture_for("photos/red-100x50.png", ImageType::Png, &bytes);
    let extractor = ImageExtractor::new();
    let doc1 = extractor.extract(&fx.ctx(), &bytes).unwrap();
    let doc2 = extractor.extract(&fx.ctx(), &bytes).unwrap();

    let mut j1 = serde_json::to_value(&doc1).unwrap();
    let mut j2 = serde_json::to_value(&doc2).unwrap();
    strip_dynamic_at(&mut j1);
    strip_dynamic_at(&mut j2);
    assert_eq!(
        j1, j2,
        "two extractions of identical bytes must serialise byte-for-byte equal (modulo dynamic timestamps)"
    );

    // Pin a few fields by exact value so a future regression in the
    // ID recipe / serialisation order surfaces here, not at the JSON
    // diff level only.
    assert_eq!(j1["title"], "red-100x50");
    assert_eq!(j1["lang"], "und");
    assert_eq!(j1["parser_version"], kebab_parse_image::PARSER_VERSION);
    assert_eq!(j1["schema_version"], 1);
    assert_eq!(j1["doc_version"], 1);
    assert_eq!(j1["blocks"].as_array().unwrap().len(), 1);
    assert_eq!(j1["blocks"][0]["kind"], "imageref");
    assert_eq!(j1["metadata"]["source_type"], "reference");
    assert_eq!(j1["metadata"]["trust_level"], "primary");
}

#[test]
fn supports_only_image_media_type() {
    let e = ImageExtractor::new();
    assert!(e.supports(&kebab_core::MediaType::Image(ImageType::Png)));
    assert!(e.supports(&kebab_core::MediaType::Image(ImageType::Jpeg)));
    assert!(!e.supports(&kebab_core::MediaType::Markdown));
    assert!(!e.supports(&kebab_core::MediaType::Pdf));
}

#[test]
fn jpeg_with_gps_missing_ref_drops_coordinates() {
    let bytes = exif_gps_no_ref_jpg();
    let fx = fixture_for("img/no-ref.jpg", ImageType::Jpeg, &bytes);
    let doc = ImageExtractor::new().extract(&fx.ctx(), &bytes).unwrap();
    let exif = doc
        .metadata
        .user
        .get("exif")
        .and_then(|v| v.as_object())
        .expect("exif object present");
    // Other whitelisted tags still load (Make / Model / …); GPS is
    // dropped because the *Ref tags are missing.
    assert!(exif.contains_key("make"));
    assert!(
        !exif.contains_key("gps_lat"),
        "missing GPSLatitudeRef must drop gps_lat"
    );
    assert!(
        !exif.contains_key("gps_lon"),
        "missing GPSLongitudeRef must drop gps_lon"
    );
}

#[test]
fn jpeg_with_gps_out_of_range_drops_latitude() {
    let bytes = exif_gps_out_of_range_jpg();
    let fx = fixture_for("img/oor.jpg", ImageType::Jpeg, &bytes);
    let doc = ImageExtractor::new().extract(&fx.ctx(), &bytes).unwrap();
    let exif = doc
        .metadata
        .user
        .get("exif")
        .and_then(|v| v.as_object())
        .expect("exif object present");
    // Latitude (300° + 30' = ~300.5) is outside ±90, so it must be
    // dropped. Longitude (127°) stays in range and survives.
    assert!(
        !exif.contains_key("gps_lat"),
        "out-of-range latitude must be dropped"
    );
    let lon = exif
        .get("gps_lon")
        .and_then(serde_json::Value::as_f64)
        .expect("gps_lon");
    assert!((lon - 127.0).abs() < 1e-6);
}

#[test]
fn rejects_extract_when_media_type_mismatches() {
    let bytes = red_100x50_png();
    let mut fx = fixture_for("a/b.md", ImageType::Png, &bytes);
    fx.asset.media_type = kebab_core::MediaType::Markdown;
    let r = ImageExtractor::new().extract(&fx.ctx(), &bytes);
    assert!(r.is_err());
}
