proofmode 0.9.0

Capture, share, and preserve verifiable photos and videos
Documentation
use crate::check::discrepancy::TemporalPatterns;
use crate::check::preprocess::ProofCheckFile;
use chrono::NaiveDateTime;
use geo::{coord, Centroid, ConvexHull, Distance, Haversine, LineString, Point, Polygon};
use geojson::{
    Feature, FeatureCollection, GeoJson, Geometry, JsonObject, JsonValue, Value as GeoJSONValue,
};
use serde::{Deserialize, Serialize};
use std::f64::consts::PI;

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TemporalAnalysis {
    pub earliest: String,
    pub latest: String,
    pub elapsed_seconds: f64,
    pub file_count: usize,
    pub patterns: Option<TemporalPatterns>,
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PairwiseDistance {
    pub file_a: String,
    pub file_b: String,
    pub distance_km: f64,
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SpatialAnalysis {
    pub max_distance_km: f64,
    pub area_km2: f64,
    pub centroid: [f64; 2],
    pub point_count: usize,
    pub pairwise_distances: Vec<PairwiseDistance>,
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Synchrony {
    pub verified: bool,
    pub geojson: GeoJson,
    pub temporal: Option<TemporalAnalysis>,
    pub spatial: Option<SpatialAnalysis>,
}

pub fn check_synchrony(proof_files: &Vec<ProofCheckFile>) -> Synchrony {
    let mut features: Vec<Feature> = vec![];
    let mut coord_files: Vec<(String, f64, f64)> = vec![];

    for file in proof_files {
        if let Some(json) = &file.json {
            if let Some(latitude) = json.location.latitude {
                if let Some(longitude) = json.location.longitude {
                    let geometry = Geometry::new(GeoJSONValue::Point(vec![longitude, latitude]));
                    let image_name = file.name.clone();

                    let mut properties = JsonObject::new();
                    properties.insert(String::from("name"), JsonValue::from(image_name.clone()));

                    let feature = Feature {
                        bbox: None,
                        geometry: Some(geometry),
                        id: None,
                        properties: Some(properties),
                        foreign_members: None,
                    };

                    features.push(feature);
                    coord_files.push((image_name, latitude, longitude));
                }
            }
        }
    }

    let mut coordinates = vec![];

    for feature in features.clone() {
        if let Some(geometry) = feature.geometry {
            if let GeoJSONValue::Point(pt) = geometry.value {
                let c = coord! {
                   x: pt[0],
                   y: pt[1],
                };

                coordinates.push(c);
            }
        }
    }

    let mut centroid_coords: [f64; 2] = [0.0, 0.0];

    let coords = LineString(coordinates);
    let polygon = Polygon::new(coords, vec![]);
    let hull = polygon.convex_hull();
    let exterior = hull.exterior();
    let _exterior_string = format!("{:?}", exterior);
    if let Some(centroid) = polygon.centroid() {
        centroid_coords = [centroid.x(), centroid.y()];
        let geojson_point = GeoJSONValue::Point(vec![centroid.x(), centroid.y()]);

        let mut properties = JsonObject::new();
        properties.insert(String::from("name"), JsonValue::from("Centroid"));

        let feature = Feature {
            bbox: None,
            geometry: Some(Geometry::new(geojson_point)),
            id: None,
            properties: Some(properties),
            foreign_members: None,
        };
        features.push(feature);
    }

    let geojson = FeatureCollection {
        bbox: None,
        features,
        foreign_members: None,
    };

    let temporal = compute_temporal_analysis(proof_files);
    let spatial = compute_spatial_analysis(&coord_files, centroid_coords);

    Synchrony {
        verified: true,
        geojson: geojson.into(),
        temporal,
        spatial,
    }
}

fn compute_temporal_analysis(proof_files: &[ProofCheckFile]) -> Option<TemporalAnalysis> {
    let mut timestamps: Vec<(NaiveDateTime, &str)> = Vec::new();

    for file in proof_files {
        if let Some(json) = &file.json {
            // Prefer location.time, fall back to file.created_at
            let time_str = json
                .location
                .time
                .as_deref()
                .or(json.file.created_at.as_deref());

            if let Some(ts) = time_str {
                if let Some(parsed) = parse_timestamp(ts) {
                    timestamps.push((parsed, &file.name));
                }
            }
        }
    }

    if timestamps.is_empty() {
        return None;
    }

    timestamps.sort_by_key(|(dt, _)| *dt);

    let earliest = timestamps.first().unwrap().0;
    let latest = timestamps.last().unwrap().0;
    let elapsed = (latest - earliest).num_milliseconds() as f64 / 1000.0;

    #[cfg(feature = "polars")]
    let patterns = crate::check::analytics::analyze_temporal_patterns(proof_files);
    #[cfg(not(feature = "polars"))]
    let patterns = None;

    Some(TemporalAnalysis {
        earliest: earliest.format("%Y-%m-%dT%H:%M:%S").to_string(),
        latest: latest.format("%Y-%m-%dT%H:%M:%S").to_string(),
        elapsed_seconds: elapsed,
        file_count: timestamps.len(),
        patterns,
    })
}

pub(crate) fn parse_timestamp(s: &str) -> Option<NaiveDateTime> {
    // Try common formats
    let formats = [
        "%Y-%m-%dT%H:%M:%S%.f",
        "%Y-%m-%dT%H:%M:%S",
        "%Y-%m-%d %H:%M:%S",
        "%Y:%m:%d %H:%M:%S",
        "%Y-%m-%dT%H:%M:%S%.fZ",
        "%Y-%m-%dT%H:%M:%SZ",
    ];

    for fmt in &formats {
        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
            return Some(dt);
        }
    }

    // Try parsing as milliseconds since epoch
    if let Ok(ms) = s.parse::<i64>() {
        return chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.naive_utc());
    }

    None
}

fn compute_spatial_analysis(
    coord_files: &[(String, f64, f64)],
    centroid_coords: [f64; 2],
) -> Option<SpatialAnalysis> {
    if coord_files.len() < 2 {
        return None;
    }

    let mut pairwise_distances: Vec<PairwiseDistance> = Vec::new();
    let mut max_distance_km: f64 = 0.0;

    for i in 0..coord_files.len() {
        for j in (i + 1)..coord_files.len() {
            let (ref name_a, lat_a, lon_a) = coord_files[i];
            let (ref name_b, lat_b, lon_b) = coord_files[j];

            let point_a = Point::new(lon_a, lat_a);
            let point_b = Point::new(lon_b, lat_b);
            let distance_m = Haversine.distance(point_a, point_b);
            let distance_km = distance_m / 1000.0;

            if distance_km > max_distance_km {
                max_distance_km = distance_km;
            }

            pairwise_distances.push(PairwiseDistance {
                file_a: name_a.clone(),
                file_b: name_b.clone(),
                distance_km,
            });
        }
    }

    let radius_km = max_distance_km / 2.0;
    let area_km2 = PI * radius_km * radius_km;

    Some(SpatialAnalysis {
        max_distance_km,
        area_km2,
        centroid: centroid_coords,
        point_count: coord_files.len(),
        pairwise_distances,
    })
}