/**
 * ExifService — photo metadata extraction, at parity with the Swift app's
 * `ExifParser` (Data/Capture/ExifParser.swift), which is canonical.
 *
 * Ordering contract (same as Swift): read metadata from the ORIGINAL file
 * bytes BEFORE any downscale/re-encode. `MouldDetectService.compressForPreview`
 * renders through a canvas, which drops every EXIF/TIFF/GPS segment — so an
 * extraction run against the compressed copy always comes back empty. See
 * ImageUpload.processFile, where both run off the same File.
 *
 * Failure-safe, like the Swift parser: never throws and never rejects. A
 * corrupt segment, an unreadable file or a missing library all yield a Fields
 * object whose members are simply null. Callers never need a try/catch.
 *
 * Reality check that shaped this file: most photos reaching a web upload have
 * NO metadata at all. Messaging apps and stock-photo sites strip it — verified
 * against real files, where WhatsApp exports and Unsplash downloads carried
 * nothing but pixel dimensions. `hasCameraData()` exists so the UI can say why
 * a panel is empty instead of just looking broken.
 *
 * Library: ExifReader (https://mattiasw.github.io/ExifReader/), MIT, ~129 KB
 * UMD from unpkg — a host already allowed by the production CSP's script-src,
 * so no infrastructure change was needed to adopt it.
 *
 * Written in ES5 for Babel 6 compatibility.
 */
var ExifService = (function () {

    // Group names as ExifReader returns them with { expanded: true }. Verified
    // against a fixture rather than assumed: ASCII tags arrive wrapped in an
    // array, rationals as [numerator, denominator], and the raw GPS* tags can
    // land under `exif` rather than `gps` depending on how the GPS IFD is
    // written — hence the two-path GPS read below.
    var SCHEMA_VERSION = 2;

    function _lib() {
        return (typeof ExifReader !== 'undefined') ? ExifReader
             : (typeof window !== 'undefined' ? window.ExifReader : null);
    }

    /** True when the ExifReader bundle loaded. Extraction degrades to
     *  dimensions-only when it did not, rather than failing the upload. */
    function isAvailable() {
        return !!_lib();
    }

    // --- tag readers -------------------------------------------------------
    // Each tolerates undefined, wrapped, and unwrapped shapes.

    function _tag(group, name) {
        if (!group) return null;
        var t = group[name];
        return (t === undefined || t === null) ? null : t;
    }

    /** Human string for a tag. Prefers ExifReader's `description` (already
     *  trimmed and decoded) and falls back to unwrapping `value`. */
    function _str(group, name) {
        var t = _tag(group, name);
        if (!t) return null;
        var d = t.description;
        if (typeof d === 'string' && d.length) return d;
        var v = t.value;
        if (Object.prototype.toString.call(v) === '[object Array]') v = v[0];
        if (typeof v === 'string' && v.length) return v;
        return null;
    }

    /** Numeric value for a tag, resolving [num, den] rationals. Returns null
     *  rather than NaN or Infinity so a zero denominator cannot reach the UI. */
    function _num(group, name) {
        var t = _tag(group, name);
        if (!t) return null;
        var v = t.value;
        if (Object.prototype.toString.call(v) === '[object Array]') {
            if (v.length === 2 && typeof v[0] === 'number' && typeof v[1] === 'number') {
                if (!v[1]) return null;
                var r = v[0] / v[1];
                return isFinite(r) ? r : null;
            }
            v = v[0];
        }
        if (typeof v === 'number' && isFinite(v)) return v;
        // Some writers store numerics as strings.
        if (typeof v === 'string') {
            var p = parseFloat(v);
            return isFinite(p) ? p : null;
        }
        return null;
    }

    function _int(group, name) {
        var n = _num(group, name);
        return (n === null) ? null : Math.round(n);
    }

    /** ISO arrives as a bare int or an array of ints, matching the Swift
     *  parser's two-branch read of kCGImagePropertyExifISOSpeedRatings. */
    function _iso(exif) {
        var t = _tag(exif, 'ISOSpeedRatings');
        if (!t) return null;
        var v = t.value;
        if (Object.prototype.toString.call(v) === '[object Array]') v = v[0];
        if (typeof v === 'number' && isFinite(v)) return Math.round(v);
        return _int(exif, 'ISOSpeedRatings');
    }

    /** EXIF `Flash` is a bitfield; bit 0 is "flash fired". Same read as Swift. */
    function _flashFired(exif) {
        var t = _tag(exif, 'Flash');
        if (!t) return null;
        var v = t.value;
        if (Object.prototype.toString.call(v) === '[object Array]') v = v[0];
        if (typeof v !== 'number' || !isFinite(v)) return null;
        return (v & 0x1) !== 0;
    }

    /**
     * EXIF DateTimeOriginal is "yyyy:MM:dd HH:mm:ss" with no timezone. Swift
     * reads it as UTC via an en_US_POSIX formatter; we do the same explicitly
     * rather than handing the string to Date(), which parses this shape
     * inconsistently across browsers.
     * Returns epoch milliseconds, or null.
     */
    function _photoTimestamp(exif) {
        var raw = _str(exif, 'DateTimeOriginal');
        if (!raw) return null;
        var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(raw);
        if (!m) return null;
        var ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
        return isFinite(ms) ? ms : null;
    }

    /**
     * GPS, two paths.
     *
     * Path 1 — ExifReader's computed `gps.Latitude`/`gps.Longitude`, already
     * signed decimal. This is the normal case for a well-formed GPS IFD.
     *
     * Path 2 — fall back to the raw `GPSLatitude` DMS triple plus
     * `GPSLatitudeRef`, applying the S/W negation ourselves. Needed because the
     * computed group is empty for some writers even when the raw tags parsed
     * fine (observed on a fixture). This mirrors the Swift parser, which always
     * applies the ref itself.
     */
    function _coord(gps, exif, computedKey, rawKey, refKey, negativeRef) {
        var direct = _num(gps, computedKey);
        if (direct !== null) return direct;

        var t = _tag(exif, rawKey) || _tag(gps, rawKey);
        if (!t) return null;

        var decimal = null;
        var v = t.value;
        if (Object.prototype.toString.call(v) === '[object Array]' && v.length === 3) {
            // [[deg,den],[min,den],[sec,den]]
            var parts = [];
            for (var i = 0; i < 3; i++) {
                var p = v[i];
                if (Object.prototype.toString.call(p) === '[object Array]') {
                    if (!p[1]) return null;
                    parts.push(p[0] / p[1]);
                } else if (typeof p === 'number') {
                    parts.push(p);
                } else {
                    return null;
                }
            }
            decimal = parts[0] + (parts[1] / 60) + (parts[2] / 3600);
        } else {
            // Some writers emit a ready-made decimal; description carries it.
            var d = parseFloat(t.description);
            if (isFinite(d)) decimal = d;
        }
        if (decimal === null || !isFinite(decimal)) return null;

        var ref = _str(exif, refKey) || _str(gps, refKey) || '';
        // description may be spelled out ("South latitude") — test the initial.
        var initial = ref.charAt(0).toUpperCase();
        if (initial === negativeRef) decimal = -decimal;
        return decimal;
    }

    // --- public shape ------------------------------------------------------

    /** Every field the Swift `ExifParser.Fields` struct carries, all nullable. */
    function emptyFields() {
        return {
            cameraMake:      null,
            cameraModel:     null,
            lensModel:       null,
            focalLengthMM:   null,
            focalLength35mm: null,
            fNumber:         null,
            exposureSeconds: null,
            iso:             null,
            flashFired:      null,
            pixelWidth:      null,
            pixelHeight:     null,
            photoTimestamp:  null,
            gpsLatitude:     null,
            gpsLongitude:    null,
        };
    }

    /** Synchronous core, exposed for tests. `tags` is ExifReader expanded output. */
    function fieldsFromTags(tags) {
        var fields = emptyFields();
        if (!tags) return fields;

        var exif = tags.exif || null;
        var file = tags.file || null;
        var gps  = tags.gps  || null;

        // Pixel dimensions come from the image itself (JPEG SOF / PNG IHDR),
        // which is why they survive metadata stripping when nothing else does.
        fields.pixelWidth  = _int(file, 'Image Width');
        fields.pixelHeight = _int(file, 'Image Height');

        fields.cameraMake      = _str(exif, 'Make');
        fields.cameraModel     = _str(exif, 'Model');
        fields.lensModel       = _str(exif, 'LensModel');
        fields.focalLengthMM   = _num(exif, 'FocalLength');
        fields.focalLength35mm = _int(exif, 'FocalLengthIn35mmFilm');
        fields.fNumber         = _num(exif, 'FNumber');
        fields.exposureSeconds = _num(exif, 'ExposureTime');
        fields.iso             = _iso(exif);
        fields.flashFired      = _flashFired(exif);
        fields.photoTimestamp  = _photoTimestamp(exif);

        fields.gpsLatitude  = _coord(gps, exif, 'Latitude',  'GPSLatitude',  'GPSLatitudeRef',  'S');
        fields.gpsLongitude = _coord(gps, exif, 'Longitude', 'GPSLongitude', 'GPSLongitudeRef', 'W');

        return fields;
    }

    /** True when anything beyond bare pixel dimensions was recovered. Drives
     *  the "metadata was stripped" explanation in the UI. */
    function hasCameraData(fields) {
        if (!fields) return false;
        var keys = ['cameraMake', 'cameraModel', 'lensModel', 'focalLengthMM',
                    'focalLength35mm', 'fNumber', 'exposureSeconds', 'iso',
                    'flashFired', 'photoTimestamp', 'gpsLatitude', 'gpsLongitude'];
        for (var i = 0; i < keys.length; i++) {
            if (fields[keys[i]] !== null && fields[keys[i]] !== undefined) return true;
        }
        return false;
    }

    function hasAnyData(fields) {
        if (!fields) return false;
        return hasCameraData(fields) || fields.pixelWidth !== null || fields.pixelHeight !== null;
    }

    /**
     * Extract from a File/Blob. Always resolves — never rejects.
     * Resolves with { fields, available, error } where `error` is a short
     * machine tag for diagnostics only, never shown to a user.
     */
    function extract(file) {
        var lib = _lib();
        if (!file) {
            return Promise.resolve({ fields: emptyFields(), available: !!lib, error: 'no_file' });
        }
        if (!lib) {
            return Promise.resolve({ fields: emptyFields(), available: false, error: 'library_unavailable' });
        }

        return _readArrayBuffer(file).then(function (buffer) {
            var tags;
            try {
                tags = lib.load(buffer, { expanded: true });
            } catch (e) {
                // ExifReader throws MetadataMissingError for a clean image with
                // no metadata at all — an expected outcome, not a failure.
                return { fields: emptyFields(), available: true, error: 'no_metadata' };
            }
            return { fields: fieldsFromTags(tags), available: true, error: null };
        })['catch'](function () {
            return { fields: emptyFields(), available: true, error: 'read_failed' };
        });
    }

    function _readArrayBuffer(file) {
        // Blob.arrayBuffer() where available; FileReader elsewhere.
        if (typeof file.arrayBuffer === 'function') {
            return file.arrayBuffer();
        }
        return new Promise(function (resolve, reject) {
            var reader = new FileReader();
            reader.onload = function (e) { resolve(e.target.result); };
            reader.onerror = function () { reject(new Error('read_failed')); };
            reader.readAsArrayBuffer(file);
        });
    }

    /**
     * Grouped payload for persistence and cloud sync.
     *
     * Field names and nesting deliberately match the Swift app's
     * `CaptureContextValues` (envelope/camera/location) so a record written by
     * either client deserialises with the other, and so the S3 backup wire
     * format stays single-shaped. Groups the web cannot populate for an
     * uploaded still — depth, motion, atmosphere, light — are omitted rather
     * than sent as nulls, so a reader can distinguish "not captured here" from
     * "captured and empty".
     */
    function toCaptureContext(fields, source) {
        var f = fields || emptyFields();
        return {
            envelope: {
                schemaVersion:  SCHEMA_VERSION,
                capturedAt:     new Date().toISOString(),
                source:         source || 'web_upload',
                photoTimestamp: (f.photoTimestamp !== null && f.photoTimestamp !== undefined)
                                    ? new Date(f.photoTimestamp).toISOString() : null,
            },
            camera: {
                make:            f.cameraMake,
                model:           f.cameraModel,
                lensModel:       f.lensModel,
                focalLengthMM:   f.focalLengthMM,
                focalLength35mm: f.focalLength35mm,
                fNumber:         f.fNumber,
                exposureSeconds: f.exposureSeconds,
                iso:             f.iso,
                flashFired:      f.flashFired,
                pixelWidth:      f.pixelWidth,
                pixelHeight:     f.pixelHeight,
            },
            location: {
                latitude:            f.gpsLatitude,
                longitude:           f.gpsLongitude,
                horizontalAccuracyM: null,
                locationSource:      (f.gpsLatitude !== null && f.gpsLatitude !== undefined) ? 'exif' : null,
            },
            device: {
                deviceModel: null,
                osVersion:   null,
                appVersion:  (typeof AppConstants !== 'undefined' && AppConstants.APP_VERSION) || null,
            },
        };
    }

    /** Inverse of toCaptureContext, for reading persisted/restored records. */
    function fieldsFromCaptureContext(ctx) {
        var fields = emptyFields();
        if (!ctx) return fields;
        var cam = ctx.camera || {};
        var loc = ctx.location || {};
        var env = ctx.envelope || {};

        fields.cameraMake      = cam.make            != null ? cam.make            : null;
        fields.cameraModel     = cam.model           != null ? cam.model           : null;
        fields.lensModel       = cam.lensModel       != null ? cam.lensModel       : null;
        fields.focalLengthMM   = cam.focalLengthMM   != null ? cam.focalLengthMM   : null;
        fields.focalLength35mm = cam.focalLength35mm != null ? cam.focalLength35mm : null;
        fields.fNumber         = cam.fNumber         != null ? cam.fNumber         : null;
        fields.exposureSeconds = cam.exposureSeconds != null ? cam.exposureSeconds : null;
        fields.iso             = cam.iso             != null ? cam.iso             : null;
        fields.flashFired      = cam.flashFired      != null ? cam.flashFired      : null;
        fields.pixelWidth      = cam.pixelWidth      != null ? cam.pixelWidth      : null;
        fields.pixelHeight     = cam.pixelHeight     != null ? cam.pixelHeight     : null;
        fields.gpsLatitude     = loc.latitude        != null ? loc.latitude        : null;
        fields.gpsLongitude    = loc.longitude       != null ? loc.longitude       : null;

        if (env.photoTimestamp) {
            var ms = Date.parse(env.photoTimestamp);
            fields.photoTimestamp = isFinite(ms) ? ms : null;
        }
        return fields;
    }

    return {
        SCHEMA_VERSION:           SCHEMA_VERSION,
        isAvailable:              isAvailable,
        extract:                  extract,
        fieldsFromTags:           fieldsFromTags,
        emptyFields:              emptyFields,
        hasCameraData:            hasCameraData,
        hasAnyData:               hasAnyData,
        toCaptureContext:         toCaptureContext,
        fieldsFromCaptureContext: fieldsFromCaptureContext,
    };
})();

window.ExifService = ExifService;
