/* Copyright 2011 Yorba Foundation
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later).  See the COPYING file in this distribution. 
 */

public struct PhotoID {
    public const int64 INVALID = -1;

    public int64 id;
    
    public PhotoID(int64 id = INVALID) {
        this.id = id;
    }
    
    public bool is_invalid() {
        return (id == INVALID);
    }
    
    public bool is_valid() {
        return (id != INVALID);
    }
    
    public static uint hash(void *a) {
        return int64_hash(&((PhotoID *) a)->id);
    }
    
    public static bool equal(void *a, void *b) {
        return ((PhotoID *) a)->id == ((PhotoID *) b)->id;
    }
    
    public static string upgrade_photo_id_to_source_id(PhotoID photo_id) {
        return ("%s%016" + int64.FORMAT_MODIFIER + "x").printf(Photo.TYPENAME, photo_id.id);
    }
}

public struct ImportID {
    public const int64 INVALID = 0;

    public int64 id;
    
    public ImportID(int64 id = INVALID) {
        this.id = id;
    }
    
    public static ImportID generate() {
        TimeVal timestamp = TimeVal();
        timestamp.get_current_time();
        int64 id = timestamp.tv_sec;
        
        return ImportID(id);
    }
    
    public bool is_invalid() {
        return (id == INVALID);
    }
    
    public bool is_valid() {
        return (id != INVALID);
    }
    
    public static int compare_func(void *a, void *b) {
        int64 cmp = comparator(a, b);
        if (cmp < 0)
            return -1;
        else if (cmp > 0)
            return 1;
        else
            return 0;
    }
    
    public static int64 comparator(void *a, void *b) {
        return ((ImportID *) a)->id - ((ImportID *) b)->id;
    }
}

public struct BackingPhotoState {
    public string? filepath;
    public int64 filesize;
    public time_t timestamp;
    public PhotoFileFormat file_format;
    public Dimensions dim;
    public Orientation original_orientation;
    
    public bool matches_file_info(FileInfo info) {
        if (filesize != info.get_size())
            return false;
        
        TimeVal modification;
        info.get_modification_time(out modification);
        
        return timestamp == modification.tv_sec;
    }
    
    public bool is_touched(FileInfo info) {
        if (filesize != info.get_size())
            return false;
        
        TimeVal modification;
        info.get_modification_time(out modification);
        
        return timestamp != modification.tv_sec;
    }
}

public struct PhotoRow {
    public PhotoID photo_id;
    public BackingPhotoState master;
    public time_t exposure_time;
    public ImportID import_id;
    public EventID event_id;
    public Orientation orientation;
    public Gee.HashMap<string, KeyValueMap>? transformations;
    public string md5;
    public string thumbnail_md5;
    public string exif_md5;
    public time_t time_created;
    public uint64 flags;
    public Rating rating;
    public string title;
    public string? backlinks;
    public time_t time_reimported;
    public BackingPhotoID editable_id;
    public bool metadata_dirty;
    
    public PhotoRow() {
        editable_id = BackingPhotoID();
    }
}

public class PhotoTable : DatabaseTable {
    private static PhotoTable instance = null;
    
    private PhotoTable() {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS PhotoTable ("
            + "id INTEGER PRIMARY KEY, "
            + "filename TEXT UNIQUE NOT NULL, "
            + "width INTEGER, "
            + "height INTEGER, "
            + "filesize INTEGER, "
            + "timestamp INTEGER, "
            + "exposure_time INTEGER, "
            + "orientation INTEGER, "
            + "original_orientation INTEGER, "
            + "import_id INTEGER, "
            + "event_id INTEGER, "
            + "transformations TEXT, "
            + "md5 TEXT, "
            + "thumbnail_md5 TEXT, "
            + "exif_md5 TEXT, "
            + "time_created INTEGER, "
            + "flags INTEGER DEFAULT 0, "
            + "rating INTEGER DEFAULT 0, "
            + "file_format INTEGER DEFAULT 0, "
            + "title TEXT, "
            + "backlinks TEXT, "
            + "time_reimported INTEGER, "
            + "editable_id INTEGER DEFAULT -1, "
            + "metadata_dirty INTEGER DEFAULT 0"
            + ")", -1, out stmt);
        assert(res == Sqlite.OK);

        res = stmt.step();
        if (res != Sqlite.DONE)
            fatal("create photo table", res);
        
        // index on event_id
        Sqlite.Statement stmt2;
        int res2 = db.prepare_v2("CREATE INDEX IF NOT EXISTS PhotoEventIDIndex ON PhotoTable (event_id)",
            -1, out stmt2);
        assert(res2 == Sqlite.OK);

        res2 = stmt2.step();
        if (res2 != Sqlite.DONE)
            fatal("create photo table", res2);

        set_table_name("PhotoTable");
    }
    
    public static PhotoTable get_instance() {
        if (instance == null)
            instance = new PhotoTable();
        
        return instance;
    }
    
    // PhotoRow.photo_id, event_id, master.orientation, flags, and time_created are ignored on input.
    // All fields are set on exit with values stored in the database.  editable_id field is ignored.
    public PhotoID add(ref PhotoRow photo_row) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2(
            "INSERT INTO PhotoTable (filename, width, height, filesize, timestamp, exposure_time, "
            + "orientation, original_orientation, import_id, event_id, md5, thumbnail_md5, "
            + "exif_md5, time_created, file_format, title, rating, editable_id) "
            + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        ulong time_created = now_sec();
        
        res = stmt.bind_text(1, photo_row.master.filepath);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(2, photo_row.master.dim.width);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(3, photo_row.master.dim.height);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(4, photo_row.master.filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(5, photo_row.master.timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(6, photo_row.exposure_time);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(7, photo_row.master.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(8, photo_row.master.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(9, photo_row.import_id.id);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(10, EventID.INVALID);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(11, photo_row.md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(12, photo_row.thumbnail_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(13, photo_row.exif_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(14, time_created);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(15, photo_row.master.file_format.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_text(16, photo_row.title);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(17, photo_row.rating.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(18, BackingPhotoID.INVALID);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            if (res != Sqlite.CONSTRAINT)
                fatal("add_photo", res);
            
            return PhotoID();
        }
        
        // fill in ignored fields with database values
        photo_row.photo_id = PhotoID(db.last_insert_rowid());
        photo_row.orientation = photo_row.master.original_orientation;
        photo_row.event_id = EventID();
        photo_row.time_created = (time_t) time_created;
        photo_row.flags = 0;
        
        return photo_row.photo_id;
    }
    
    // The only fields recognized in the PhotoRow are photo_id, dimensions,
    // filesize, timestamp, exposure_time, original_orientation, file_format,
    // and the md5 fields.  When the method returns, time_reimported and master.orientation has been 
    // updated.  editable_id is ignored.  transformations are untouched; use
    // remove_all_transformations() if necessary.
    public void reimport(ref PhotoRow row) throws DatabaseError {
        Sqlite.Statement stmt;
        int res = db.prepare_v2(
            "UPDATE PhotoTable SET width = ?, height = ?, filesize = ?, timestamp = ?, "
            + "exposure_time = ?, orientation = ?, original_orientation = ?, md5 = ?, " 
            + "exif_md5 = ?, thumbnail_md5 = ?, file_format = ?, title = ?, time_reimported = ? "
            + "WHERE id = ?", -1, out stmt);
        assert(res == Sqlite.OK);
        
        time_t time_reimported = (time_t) now_sec();
        
        res = stmt.bind_int(1, row.master.dim.width);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(2, row.master.dim.height);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(3, row.master.filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(4, row.master.timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(5, row.exposure_time);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(6, row.master.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(7, row.master.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(8, row.md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(9, row.exif_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(10, row.thumbnail_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(11, row.master.file_format.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_text(12, row.title);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(13, time_reimported);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(14, row.photo_id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE)
            throw_error("PhotoTable.reimport_master", res);
        
        row.time_reimported = time_reimported;
        row.orientation = row.master.original_orientation;
    }

    public bool master_exif_updated(PhotoID photoID, int64 filesize, long timestamp, 
        string md5, string? exif_md5, string? thumbnail_md5, ref PhotoRow row) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2(
            "UPDATE PhotoTable SET filesize = ?, timestamp = ?, md5 = ?, exif_md5 = ?,"
            + "thumbnail_md5 =? WHERE id = ?", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(2, timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(3, md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(4, exif_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(5, thumbnail_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(6, photoID.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            if (res != Sqlite.CONSTRAINT)
                fatal("write_update_photo", res);
            
            return false;
        }
        
        row.master.filesize = filesize;
        row.master.timestamp = timestamp;
        row.md5 = md5;
        row.exif_md5 = exif_md5;
        row.thumbnail_md5 = thumbnail_md5;
        
        return true;
    }
    
    public PhotoRow? get_row(PhotoID photo_id) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2(
            "SELECT filename, width, height, filesize, timestamp, exposure_time, orientation, "
            + "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, "
            + "exif_md5, time_created, flags, rating, file_format, title, backlinks, "
            + "time_reimported, editable_id, metadata_dirty "
            + "FROM PhotoTable WHERE id=?", 
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, photo_id.id);
        assert(res == Sqlite.OK);
        
        if (stmt.step() != Sqlite.ROW)
            return null;
            
        PhotoRow row = PhotoRow();
        row.photo_id = photo_id;
        row.master.filepath = stmt.column_text(0);
        row.master.dim = Dimensions(stmt.column_int(1), stmt.column_int(2));
        row.master.filesize = stmt.column_int64(3);
        row.master.timestamp = (time_t) stmt.column_int64(4);
        row.exposure_time = (time_t) stmt.column_int64(5);
        row.orientation = (Orientation) stmt.column_int(6);
        row.master.original_orientation = (Orientation) stmt.column_int(7);
        row.import_id.id = stmt.column_int64(8);
        row.event_id.id = stmt.column_int64(9);
        row.transformations = marshall_all_transformations(stmt.column_text(10));
        row.md5 = stmt.column_text(11);
        row.thumbnail_md5 = stmt.column_text(12);
        row.exif_md5 = stmt.column_text(13);
        row.time_created = (time_t) stmt.column_int64(14);
        row.flags = stmt.column_int64(15);
        row.rating = Rating.unserialize(stmt.column_int(16));
        row.master.file_format = PhotoFileFormat.unserialize(stmt.column_int(17));
        row.title = stmt.column_text(18);
        row.backlinks = stmt.column_text(19);
        row.time_reimported = (time_t) stmt.column_int64(20);
        row.editable_id = BackingPhotoID(stmt.column_int64(21));
        row.metadata_dirty = stmt.column_int(22) != 0;
        
        return row;
    }
    
    public Gee.ArrayList<PhotoRow?> get_all() {
        Sqlite.Statement stmt;
        int res = db.prepare_v2(
            "SELECT id, filename, width, height, filesize, timestamp, exposure_time, orientation, "
            + "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, "
            + "exif_md5, time_created, flags, rating, file_format, title, backlinks, time_reimported, "
            + "editable_id, metadata_dirty FROM PhotoTable", 
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        Gee.ArrayList<PhotoRow?> all = new Gee.ArrayList<PhotoRow?>();
        
        while ((res = stmt.step()) == Sqlite.ROW) {
            PhotoRow row = PhotoRow();
            row.photo_id.id = stmt.column_int64(0);
            row.master.filepath = stmt.column_text(1);
            row.master.dim = Dimensions(stmt.column_int(2), stmt.column_int(3));
            row.master.filesize = stmt.column_int64(4);
            row.master.timestamp = (time_t) stmt.column_int64(5);
            row.exposure_time = (time_t) stmt.column_int64(6);
            row.orientation = (Orientation) stmt.column_int(7);
            row.master.original_orientation = (Orientation) stmt.column_int(8);
            row.import_id.id = stmt.column_int64(9);
            row.event_id.id = stmt.column_int64(10);
            row.transformations = marshall_all_transformations(stmt.column_text(11));
            row.md5 = stmt.column_text(12);
            row.thumbnail_md5 = stmt.column_text(13);
            row.exif_md5 = stmt.column_text(14);
            row.time_created = (time_t) stmt.column_int64(15);
            row.flags = stmt.column_int64(16);
            row.rating = Rating.unserialize(stmt.column_int(17));
            row.master.file_format = PhotoFileFormat.unserialize(stmt.column_int(18));
            row.title = stmt.column_text(19);
            row.backlinks = stmt.column_text(20);
            row.time_reimported = (time_t) stmt.column_int64(21);
            row.editable_id = BackingPhotoID(stmt.column_int64(22));
            row.metadata_dirty = stmt.column_int(23) != 0;
            
            all.add(row);
        }
        
        return all;
    }
    
    // Create a duplicate of the specified row.  A new byte-for-byte duplicate (including filesystem
    // metadata) of PhotoID's file  needs to back this duplicate and its editable (if exists).
    public PhotoID duplicate(PhotoID photo_id, string new_filename, BackingPhotoID editable_id) {
        // get a copy of the original row, duplicating most (but not all) of it
        PhotoRow original = get_row(photo_id);
        
        Sqlite.Statement stmt;
        int res = db.prepare_v2("INSERT INTO PhotoTable (filename, width, height, filesize, "
            + "timestamp, exposure_time, orientation, original_orientation, import_id, event_id, "
            + "transformations, md5, thumbnail_md5, exif_md5, time_created, flags, rating, "
            + "file_format, title, editable_id) "
            + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_text(1, new_filename);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(2, original.master.dim.width);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(3, original.master.dim.height);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(4, original.master.filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(5, original.master.timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(6, original.exposure_time);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(7, original.orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(8, original.master.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(9, original.import_id.id);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(10, original.event_id.id);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(11, unmarshall_all_transformations(original.transformations));
        assert(res == Sqlite.OK);
        res = stmt.bind_text(12, original.md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(13, original.thumbnail_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(14, original.exif_md5);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(15, now_sec());
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(16, (int64) original.flags);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(17, original.rating.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_int(18, original.master.file_format.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_text(19, original.title);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(20, editable_id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            if (res != Sqlite.CONSTRAINT)
                fatal("duplicate", res);
            
            return PhotoID();
        }
        
        return PhotoID(db.last_insert_rowid());
    }
    
    public bool set_title(PhotoID photo_id, string? new_title) {
       return update_text_by_id(photo_id.id, "title", new_title != null ? new_title : "");
    }
    
    public void set_filepath(PhotoID photo_id, string filepath) throws DatabaseError {
        update_text_by_id_2(photo_id.id, "filename", filepath);
    }
    
    public void update_timestamp(PhotoID photo_id, time_t timestamp) throws DatabaseError {
        update_int64_by_id_2(photo_id.id, "timestamp", timestamp);
    }
    
    public bool set_exposure_time(PhotoID photo_id, time_t time) {
        return update_int64_by_id(photo_id.id, "exposure_time", (int64) time);
    }
    
    public void set_import_id(PhotoID photo_id, ImportID import_id) throws DatabaseError {
        update_int64_by_id_2(photo_id.id, "import_id", import_id.id);
    }
    
    public bool remove_by_file(File file) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("DELETE FROM PhotoTable WHERE filename=?", -1, out stmt);
        assert(res == Sqlite.OK);

        res = stmt.bind_text(1, file.get_path());
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            warning("remove", res);
            
            return false;
        }
        
        return true;
    }
    
    public void remove(PhotoID photo_id) throws DatabaseError {
        delete_by_id(photo_id.id);
    }
    
    public Gee.ArrayList<PhotoID?> get_photos() {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("SELECT id FROM PhotoTable", -1, out stmt);
        assert(res == Sqlite.OK);

        Gee.ArrayList<PhotoID?> photo_ids = new Gee.ArrayList<PhotoID?>();
        for (;;) {
            res = stmt.step();
            if (res == Sqlite.DONE) {
                break;
            } else if (res != Sqlite.ROW) {
                fatal("get_photos", res);

                break;
            }
            
            photo_ids.add(PhotoID(stmt.column_int64(0)));
        }
        
        return photo_ids;
    }
    
    public bool set_orientation(PhotoID photo_id, Orientation orientation) {
        return update_int_by_id(photo_id.id, "orientation", (int) orientation);
    }
    
    public bool replace_flags(PhotoID photo_id, uint64 flags) {
        return update_int64_by_id(photo_id.id, "flags", (int64) flags);
    }
    
    public bool set_rating(PhotoID photo_id, Rating rating) {
        return update_int_by_id(photo_id.id, "rating", rating.serialize());
    }
    
    public int get_event_photo_count(EventID event_id) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ?", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, event_id.id);
        assert(res == Sqlite.OK);
        
        int count = 0;
        for (;;) {
            res = stmt.step();
            if (res == Sqlite.DONE) {
                break;
            } else if (res != Sqlite.ROW) {
                fatal("get_event_photo_count", res);
                
                break;
            }
            
            count++;
        }
        
        return count;
    }
    
    public Gee.ArrayList<string> get_event_source_ids(EventID event_id) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ?", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, event_id.id);
        assert(res == Sqlite.OK);
        
        Gee.ArrayList<string> result = new Gee.ArrayList<string>();
        for(;;) {
            res = stmt.step();
            if (res == Sqlite.DONE) {
                break;
            } else if (res != Sqlite.ROW) {
                fatal("get_event_source_ids", res);

                break;
            }
            
            result.add(PhotoID.upgrade_photo_id_to_source_id(PhotoID(stmt.column_int64(0))));
        }
        
        return result;
    }
    
    public bool event_has_photos(EventID event_id) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ? LIMIT 1", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, event_id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res == Sqlite.DONE) {
            return false;
        } else if (res != Sqlite.ROW) {
            fatal("event_has_photos", res);
            
            return false;
        }
        
        return true;
    }
    
    public bool drop_event(EventID event_id) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("UPDATE PhotoTable SET event_id = ? WHERE event_id = ?", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, EventID.INVALID);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(2, event_id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            fatal("drop_event", res);
            
            return false;
        }
        
        return true;
    }

    public bool set_event(PhotoID photo_id, EventID event_id) {
        return update_int64_by_id(photo_id.id, "event_id", event_id.id);
    }
    
    private string? get_raw_transformations(PhotoID photo_id) {
        Sqlite.Statement stmt;
        if (!select_by_id(photo_id.id, "transformations", out stmt))
            return null;
        
        string trans = stmt.column_text(0);
        if (trans == null || trans.length == 0)
            return null;
        
        return trans;
    }
    
    private bool set_raw_transformations(PhotoID photo_id, string trans) {
        return update_text_by_id(photo_id.id, "transformations", trans);
    }
    
    public bool set_transformation_state(PhotoID photo_id, Orientation orientation,
        Gee.HashMap<string, KeyValueMap>? transformations) {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("UPDATE PhotoTable SET orientation = ?, transformations = ? WHERE id = ?",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int(1, orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_text(2, unmarshall_all_transformations(transformations));
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(3, photo_id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE) {
            fatal("set_transformation_state", res);
            
            return false;
        }
        
        return true;
    }
    
    public static Gee.HashMap<string, KeyValueMap>? marshall_all_transformations(string? trans) {
        if (trans == null || trans.length == 0)
            return null;
            
        try {
            KeyFile keyfile = new KeyFile();
            if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
                return null;
            
            Gee.HashMap<string, KeyValueMap> map = new Gee.HashMap<string, KeyValueMap>(str_hash,
                str_equal, direct_equal);
            
            string[] objects = keyfile.get_groups();
            foreach (string object in objects) {
                string[] keys = keyfile.get_keys(object);
                if (keys == null || keys.length == 0)
                    continue;
                
                KeyValueMap key_map = new KeyValueMap(object);
                for (int ctr = 0; ctr < keys.length; ctr++)
                    key_map.set_string(keys[ctr], keyfile.get_string(object, keys[ctr]));
                
                map.set(object, key_map);
            }
            
            return map;
        } catch (Error err) {
            error("%s", err.message);
        }
    }
    
    public static string? unmarshall_all_transformations(Gee.HashMap<string, KeyValueMap>? transformations) {
        if (transformations == null || transformations.keys.size == 0)
            return null;
        
        KeyFile keyfile = new KeyFile();
        
        foreach (string object in transformations.keys) {
            KeyValueMap map = transformations.get(object);
            
            foreach (string key in map.get_keys()) {
                string? value = map.get_string(key, null);
                assert(value != null);
                
                keyfile.set_string(object, key, value);
            }
        }
        
        size_t length;
        string unmarshalled = keyfile.to_data(out length);
        assert(unmarshalled != null);
        assert(unmarshalled.length > 0);
        
        return unmarshalled;
    }
    
    public bool set_transformation(PhotoID photo_id, KeyValueMap map) {
        string trans = get_raw_transformations(photo_id);
        
        try {
            KeyFile keyfile = new KeyFile();
            if (trans != null) {
                if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
                    return false;
            }
            
            Gee.Set<string> keys = map.get_keys();
            foreach (string key in keys) {
                string value = map.get_string(key, null);
                assert(value != null);
                
                keyfile.set_string(map.get_group(), key, value);
            }
            
            size_t length;
            trans = keyfile.to_data(out length);
            assert(trans != null);
            assert(trans.length > 0);
        } catch (Error err) {
            error("%s", err.message);
        }
        
        return set_raw_transformations(photo_id, trans);
    }
    
    public bool remove_transformation(PhotoID photo_id, string object) {
        string trans = get_raw_transformations(photo_id);
        if (trans == null)
            return true;
        
        try {
            KeyFile keyfile = new KeyFile();
            if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
                return false;
            
            if (!keyfile.has_group(object))
                return true;
            
            keyfile.remove_group(object);
            
            size_t length;
            trans = keyfile.to_data(out length);
            assert(trans != null);
        } catch (Error err) {
            error("%s", err.message);
        }
        
        return set_raw_transformations(photo_id, trans);
    }
    
    public bool remove_all_transformations(PhotoID photo_id) {
        if (get_raw_transformations(photo_id) == null)
            return false;
        
        return update_text_by_id(photo_id.id, "transformations", "");
    }
    
    // Use PhotoFileFormat.UNKNOWN if not to search for matching file format; it's only used if
    // searching for MD5 duplicates.
    private Sqlite.Statement get_duplicate_stmt(File? file, string? thumbnail_md5, string? md5,
        PhotoFileFormat file_format) {
        assert(file != null || thumbnail_md5 != null || md5 != null);
        
        string sql = "SELECT id FROM PhotoTable WHERE";
        bool first = true;
        
        if (file != null) {
            sql += " filename=?";
            first = false;
        }
        
        if (thumbnail_md5 != null || md5 != null) {
            if (first)
                sql += " ((";
            else
                sql += " OR ((";
            first = false;
            
            if (thumbnail_md5 != null)
                sql += " thumbnail_md5=?";
            
            if (md5 != null) {
                if (thumbnail_md5 == null)
                    sql += " md5=?";
                else
                    sql += " OR md5=?";
            }
            
            sql += ")";
            
            if (file_format != PhotoFileFormat.UNKNOWN)
                sql += " AND file_format=?";
            
            sql += ")";
        }
        
        Sqlite.Statement stmt;
        int res = db.prepare_v2(sql, -1, out stmt);
        assert(res == Sqlite.OK);
        
        int col = 1;
        
        if (file != null) {
            res = stmt.bind_text(col++, file.get_path());
            assert(res == Sqlite.OK);
        }
        
        if (thumbnail_md5 != null) {
            res = stmt.bind_text(col++, thumbnail_md5);
            assert(res == Sqlite.OK);
        }
        
        if (md5 != null) {
            res = stmt.bind_text(col++, md5);
            assert(res == Sqlite.OK);
        }
        
        if ((thumbnail_md5 != null || md5 != null) && file_format != PhotoFileFormat.UNKNOWN) {
            res = stmt.bind_int(col++, file_format.serialize());
            assert(res == Sqlite.OK);
        }

        return stmt;
    }

    public bool has_duplicate(File? file, string? thumbnail_md5, string? md5, PhotoFileFormat file_format) {
        Sqlite.Statement stmt = get_duplicate_stmt(file, thumbnail_md5, md5, file_format);
        int res = stmt.step();
        
        if (res == Sqlite.DONE) {
            // not found
            return false;
        } else if (res == Sqlite.ROW) {
            // at least one found
            return true;
        } else {
            fatal("has_duplicate", res);
            
            return false;
        }
    }
    
    public PhotoID[] get_duplicate_ids(File? file, string? thumbnail_md5, string? md5,
        PhotoFileFormat file_format) {
        Sqlite.Statement stmt = get_duplicate_stmt(file, thumbnail_md5, md5, file_format);
        
        PhotoID[] ids = new PhotoID[0];

        int res = stmt.step();
        while (res == Sqlite.ROW) {
            ids += PhotoID(stmt.column_int64(0));
            res = stmt.step();
        }

        return ids;
    }
    
    public void update_backlinks(PhotoID photo_id, string? backlinks) throws DatabaseError {
        update_text_by_id_2(photo_id.id, "backlinks", backlinks != null ? backlinks : "");
    }
    
    public void attach_editable(ref PhotoRow row, BackingPhotoID editable_id) throws DatabaseError {
        update_int64_by_id_2(row.photo_id.id, "editable_id", editable_id.id);
        
        row.editable_id = editable_id;
    }
    
    public void detach_editable(ref PhotoRow row) throws DatabaseError {
        update_int64_by_id_2(row.photo_id.id, "editable_id", BackingPhotoID.INVALID);
        
        row.editable_id = BackingPhotoID();
    }
    
    public void set_metadata_dirty(PhotoID photo_id, bool dirty) throws DatabaseError {
        update_int_by_id_2(photo_id.id, "metadata_dirty", dirty ? 1 : 0);
    }
}

//
// BackingPhotoTable
//
// BackingPhotoTable is designed to hold any number of alternative backing photos
// for a Photo.  In the first implementation it was designed for editable photos (Edit with
// External Editor), but if other such alternates are needed, this is where to store them.
//
// Note that no transformations are held here.
//

public struct BackingPhotoID {
    public const int64 INVALID = -1;

    public int64 id;
    
    public BackingPhotoID(int64 id = INVALID) {
        this.id = id;
    }
    
    public bool is_invalid() {
        return (id == INVALID);
    }
    
    public bool is_valid() {
        return (id != INVALID);
    }
}

public struct BackingPhotoRow {
    public BackingPhotoID id;
    public time_t time_created;
    public BackingPhotoState state;
}

public class BackingPhotoTable : DatabaseTable {
    private static BackingPhotoTable instance = null;
    
    private BackingPhotoTable() {
        set_table_name("BackingPhotoTable");
        
        Sqlite.Statement stmt;
        int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
            + "BackingPhotoTable "
            + "("
            + "id INTEGER PRIMARY KEY, "
            + "filepath TEXT UNIQUE NOT NULL, "
            + "timestamp INTEGER, "
            + "filesize INTEGER, "
            + "width INTEGER, "
            + "height INTEGER, "
            + "original_orientation INTEGER, "
            + "file_format INTEGER, "
            + "time_created INTEGER "
            + ")", -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE)
            fatal("create PhotoBackingTable", res);
    }
    
    public static BackingPhotoTable get_instance() {
        if (instance == null)
            instance = new BackingPhotoTable();
        
        return instance;
    }
    
    public BackingPhotoRow add(BackingPhotoState state) throws DatabaseError {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("INSERT INTO BackingPhotoTable "
            + "(filepath, timestamp, filesize, width, height, original_orientation, "
            + "file_format, time_created) "
            + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        time_t time_created = (time_t) now_sec();
        
        res = stmt.bind_text(1, state.filepath);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(2, state.timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(3, state.filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(4, state.dim.width);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(5, state.dim.height);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(6, state.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(7, state.file_format.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(8, (int64) time_created);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE)
            throw_error("PhotoBackingTable.add", res);
        
        BackingPhotoRow row = BackingPhotoRow();
        row.id = BackingPhotoID(db.last_insert_rowid());
        row.time_created = time_created;
        row.state = state;
        
        return row;
    }
    
    public BackingPhotoRow? fetch(BackingPhotoID id) throws DatabaseError {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("SELECT filepath, timestamp, filesize, width, height, "
            + "original_orientation, file_format, time_created FROM BackingPhotoTable WHERE id=?",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res == Sqlite.DONE)
            return null;
        else if (res != Sqlite.ROW)
            throw_error("BackingPhotoTable.fetch_for_photo", res);
        
        BackingPhotoRow row = BackingPhotoRow();
        row.id = id;
        row.state.filepath = stmt.column_text(0);
        row.state.timestamp = (time_t) stmt.column_int64(1);
        row.state.filesize = stmt.column_int64(2);
        row.state.dim = Dimensions(stmt.column_int(3), stmt.column_int(4));
        row.state.original_orientation = (Orientation) stmt.column_int(5);
        row.state.file_format = PhotoFileFormat.unserialize(stmt.column_int(6));
        row.time_created = (time_t) stmt.column_int64(7);
        
        return row;
    }
    
    // Everything but filepath is updated.
    public void update(BackingPhotoID id, BackingPhotoState state) throws DatabaseError {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("UPDATE BackingPhotoTable SET timestamp=?, filesize=?, "
            + "width=?, height=?, original_orientation=?, file_format=? "
            + "WHERE id=?",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, state.timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(2, state.filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(3, state.dim.width);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(4, state.dim.height);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(5, state.original_orientation);
        assert(res == Sqlite.OK);
        res = stmt.bind_int(6, state.file_format.serialize());
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(7, id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE)
            throw_error("BackingPhotoTable.update", res);
    }
    
    public void update_attributes(BackingPhotoID id, time_t timestamp, int64 filesize) throws DatabaseError {
        Sqlite.Statement stmt;
        int res = db.prepare_v2("UPDATE BackingPhotoTable SET timestamp=?, filesize=? WHERE id=?",
            -1, out stmt);
        assert(res == Sqlite.OK);
        
        res = stmt.bind_int64(1, timestamp);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(2, filesize);
        assert(res == Sqlite.OK);
        res = stmt.bind_int64(3, id.id);
        assert(res == Sqlite.OK);
        
        res = stmt.step();
        if (res != Sqlite.DONE)
            throw_error("BackingPhotoTable.update_attributes", res);
    }
    
    public void remove(BackingPhotoID backing_id) throws DatabaseError {
        delete_by_id(backing_id.id);
    }
    
    public void set_filepath(BackingPhotoID id, string filepath) throws DatabaseError {
        update_text_by_id_2(id.id, "filepath", filepath);
    }
    
    public void update_timestamp(BackingPhotoID id, time_t timestamp) throws DatabaseError {
        update_int64_by_id_2(id.id, "timestamp", timestamp);
    }
}

