// header

#include "res/zxdb/ZXDB_version.h"

typedef struct zxdb {
    char *copy, *tok;
    char *ids[9];        // "#id|year|title|alias|publisher|type|score|genre|#tags"
    char *authors[16];   // "@role author(team)"
    char *downloads[64]; // "/file_link|release_seq|filetype_id|filetype"
} zxdb;

bool  zxdb_init(const char *zxdbfile);
bool  zxdb_initmem(const char *blob, const int len);
bool  zxdb_loaded();
int   zxdb_count();
zxdb   zxdb_search(const char *entry, int allow_multiple); // either "#id", "*text*search*", or "/file.ext"
zxdb    zxdb_print(const zxdb);
char*   zxdb_url(const zxdb, const char *hint);
char*   zxdb_download(const zxdb, const char *url, int *len); // must free() after use
int     zxdb_model(const zxdb);
zxdb    zxdb_free(zxdb);

zxdb    zxdb_new(const char *lines);
zxdb    zxdb_dup(const zxdb);


// impl
#include <string.h>

struct map zxdb2;
char *zxdb_alloc;

int zxdb_count() {
    return map_count(&zxdb2);
}

bool zxdb_loaded() {
    return !!zxdb_alloc;
}

bool zxdb_init(const char *zxdbfile) {
    int len; char *blob = readfile(zxdbfile, &len);
    if( blob && len && !zxdb_initmem(blob, len) ) return free(blob), false;
    return true;
}

bool zxdb_initmem(const char *blob, const int len) {
    // allocate db
    if(zxdb_alloc) return false;
    if(!(blob && len)) return false;

    // uncompress
    char *unc = NULL;
    unsigned unclen = 0;

    // ensure it's our gzipped database
    if( !memcmp(blob + 0000, "\x1f\x8b\x08",3) ) {
        if( !memcmp(blob + 0x0A, "Spectral.db",11) ) {
            unc = gunzip(blob, len, &unclen);
        }
    }
    if( !memcmp(blob + 0000, "Rar!",4) ) {
        if( !memcmp(blob + 0x38, "Spectral.db",11) ) {
            unc = unrar_mem(blob, len, &unclen);
        }
    }

    // check results
    if( !unc ) { alert("cannot uncompress .db file"); return false; }

    // parse db. insert every entry
    for( char *ptr = unc; (ptr < (unc+unclen)) && *ptr; ) {
        char *entry = ptr;

        char *lf;
        for( ;; ) {
            lf = strchr(ptr, '\n');
            if(!lf) { ptr += strlen(ptr); break; } // go to eof
            ptr = ++lf;
            if(ptr >= (unc+unclen)) break;
            if(*ptr != '@' && *ptr != '/') break; // break at new entry
        }

        // inscribe entry, which goes from [entry,ptr)
        ptr[-1] = '\0';
        char *zxid;
        char *year  = strchr(entry, '|')+1; assert(year);  zxid  = va("#%.*s", year-entry-1, entry);
        char *title = strchr(year,  '|')+1; assert(title);
        char *alias = strchr(title, '|')+1; assert(alias); title = va("%.*s", alias-title-1, title);
        char *brand = strchr(alias, '|')+1; assert(brand); alias = va("%.*s", brand-alias-1, alias);
//      char *avail = strchr(brand, '|')+1; assert(avail);
//      char *score = strchr(avail, '|')+1; assert(score);
//      char *genre = strchr(score, '|')+1; assert(genre);
//      char *ttags = strchr(genre, '|')+1; assert(ttags);

        map_insert(&zxdb2, zxid, entry);
        map_insert(&zxdb2, title, entry);
        if( alias[0] ) map_insert(&zxdb2, alias, entry);

        char *roman = romanize(title);
        if( strcmp(roman, title) )
            map_insert(&zxdb2, roman, entry);
    }

    printf("%d ZXDB entries\n", map_count(&zxdb2));

    // do not free(). keep pointers alive
    zxdb_alloc = unc;
    return true;
}

static
void zxdb_add_ids(zxdb *z, char **ids) {
    for( int i = 0, j = 0; ids[i]; ++i) {
        j = i;
        if( j >= countof(z->ids) ) continue;
        z->ids[j++] = ids[i];
    }
}

static
void zxdb_add_authors(zxdb *z, char **authors) {
    for( int i = 0, j = 0; authors[i]; ++i) {
        if( j >= countof(z->authors) ) continue;
        z->authors[j++] = authors[i];
    }
}

static
void zxdb_add_downloads(zxdb *z, char **downloads) {
    for( int i = 0, j = 0; downloads[i]; ++i) {
        if( j >= countof(z->downloads) ) continue;

        // .headers on; select * from filetypes ORDER BY id;
        const char accepted_types[] = {
            1,  // Loading screen
            2,  // Running screen
            4,  // Inlay - Side
            5,  // Inlay - Front
            6,  // Inlay - Back
            7,  // Media scan
            8,  // Tape image
            10, // Snapshot image
            11, // Disk image
            17, // Computer/ZX Interface 2 cartridge ROM image dump
            18, // DOCK cartridge ROM image dump
            22, // BUGFIX tape image
            23, // Ripped AY
            27, // Bonus soundtrack(s) in MP3 format
            28, // Instructions
            29, // Scanned instructions
            30, // Keyboard overlay
            31, // Game map
            33, // Code sheet
            59, // Comic
            63, // RZX playback file
            67, // ZX Interface 2 cartridge box scan
            74, // POK pokes file
            0,
        };

        // exclude types we are not likely going to ever use.
        char download_type = atoi(strchr(strchr(downloads[i],'|')+1,'|')+1);
        if( !strchr(accepted_types, download_type) ) continue;

        // exclude .pdf instructions. i dont really want to support this right now
        char *ext = strrchr(downloads[i], '.');
        if( ext && !memcmp(ext, ".pdf", 4) ) continue;

        // add download
        z->downloads[j++] = (downloads[i] + 6 * !strncmp(downloads[i], "http:/", 6) + 7 * !strncmp(downloads[i], "https:/", 7) );
    }
}


static
int zxdb_compare(const void *arg1, const void *arg2) {
    // the zxdb database is sorted by game id (#), there are no collisions there.
    // however, we use title and alias strings for our lookups. there are like 2,000
    // conflicting names in the database, among titles and aliases. here we try to choose
    // which conflicting part should be kept. see: barbarian, eliminator, etc.
    //
    // for now, using the `brand vs brandless` heuristic and the `higher score` one.
    //
    // other heuristics we could use:
    // recent vs old
    // known brand vs unknown brand

    char **a = (char**)*(VAL**)arg1; char *entry = *a;
    char **b = (char**)*(VAL**)arg2; char *other = *b;

    char *year1  = strchr(entry,  '|')+1;
    char *title1 = strchr(year1,  '|')+1;
    char *alias1 = strchr(title1, '|')+1;
    char *brand1 = strchr(alias1, '|')+1;
    char *avail1 = strchr(brand1, '|')+1;
    char *score1 = strchr(avail1, '|')+1;
    char *genre1 = strchr(score1, '|')+1;
    char *ttags1 = strchr(genre1, '|')+1;

    char *year2  = strchr(other,  '|')+1;
    char *title2 = strchr(year2,  '|')+1;
    char *alias2 = strchr(title2, '|')+1;
    char *brand2 = strchr(alias2, '|')+1;
    char *avail2 = strchr(brand2, '|')+1;
    char *score2 = strchr(avail2, '|')+1;
    char *genre2 = strchr(score2, '|')+1;
    char *ttags2 = strchr(genre2, '|')+1;

    if( !!brand1[0] ^ !!brand2[0] ) // brand vs brandless: prefer brand
        return brand2[0] - brand1[0];

    if( atof(score2) - atof(score1) ) // score: prefer higher
        return (int)(atof(score2) - atof(score1) * 100);

    return 0;
}

zxdb zxdb_new(const char *found) {
    zxdb z = {0};
    z.copy = strdup(found);
    z.tok = strdup(found);

    char *lines[128] = {0}, **line = lines, *sep;

    // split tok into lines. ignore entry strings (\r\n)
    sep = z.tok;
    for( char *ptr = strsep(&sep, "\r\n" ); ptr; ptr = strsep(&sep, "\r\n") ) {
        if( ptr[0] ) *line++ = ptr;
    }

    // inscribe download lines [2..]
    if( (lines[1] && lines[1][0] == '/') || (lines[1] && lines[2] && lines[2][0] == '/') )
    zxdb_add_downloads(&z, &lines[1 + (lines[1][0] == '@')]); // pick line [1] or [2] depending on line[1] being an @authors line

    // inscribe authors' line [1]. ignore entry strings (\r\n)
    if( lines[1] && lines[1][0] == '@' ) {
        char **authors = line;
        sep = lines[1];
        for( char *ptr = strsep(&sep, "@\r\n" ); ptr; ptr = strsep(&sep, "@\r\n" ) ) {
            if( ptr[0] ) *line++ = ptr;
        }
        zxdb_add_authors(&z, authors);
    }

    // inscribe main line [0]. include empty strings (1983||Manic|)
    {
        char **ids = line;
        sep = lines[0];
        for( char *ptr = strsep(&sep, "|\r\n" ); ptr; ptr = strsep(&sep, "|\r\n" ) ) {
            *line++ = ptr;
        }
        zxdb_add_ids(&z, ids);
    }

    return z;
}

zxdb zxdb_dup(const zxdb zxdb) {
    return zxdb_new(zxdb.copy);
}

static
zxdb zxdb_search_by_name(const char *name, int allow_multiple) {
    zxdb z = {0};

    if( *name == '\0' ) return z;
    if(name[0] != '#') puts(name);

    VAL *found = 0;

    // search query
    int matches;
    VAL **multi = map_multifind(&zxdb2, name, &matches);
    if( matches ) {
        qsort(multi, matches, sizeof(VAL*), zxdb_compare);
#if DEV
        if( matches > 1 ) ; //alert(va("%d matches for `%s`", matches, name));
        if( matches > 1 )
        for( int i = 0; i < matches; ++i ) {
            puts(*multi[i]);
        }
#endif
        if( matches == 2 && !strcmp(*multi[0], *multi[1]) ) matches = 1; // dismiss main title vs alias counting as different titles. eg, star control vs starcontrol

        if( allow_multiple ? 1 : matches == 1 )
        found = multi[0];
    }

    // process query
    if( found ) {
        z = zxdb_new(*found);
    }

    free(multi);
    return z;
}

zxdb zxdb_print(const zxdb z) {
    z.copy && puts(z.copy);
    return z;
}

zxdb zxdb_free(zxdb z) {
    zxdb zero = {0};
    free(z.copy);
    free(z.tok);
    return zero;
}

static char *zxdb_filename2title(const char *filename, int full_form) {

    // early exit on foreign filenames (@fixme: utf8 games; russian? spanish? czech?)
    for(int i = 0; filename[i]; ++i ) {
        if( filename[i] < 0 ) return va("");
    }

    // find basename
    const char *win = strrchr(filename, '\\');
    const char *unx = strrchr(filename, '/');

    // extract basename
    char *s = va("%s", win > unx ? win + 1 : unx > win ? unx + 1 : filename);

    // remove extensions
    // @todo: filter DB search by extension. ie, search exclusively for +3 disks if .dsk extension is found
    char *ext;
    do {
        ext = strlen(s) > 4 ? s + strlen(s) - 4 : 0;
        /**/ if( ext && ext[0] == '.' && !strchr(ext+1, '.') ) ext[0] = '\0'; // .rar .zip .tap .tzx etc
        else if( ext && ext[1] == '.' && !strchr(ext+1, '.') ) ext[1] = '\0'; // .gz
        else ext = 0;
    } while(ext);

    // trim (year)(publisher)(side 1)[48-128K]
    if( strstr(s," (") ) *strstr(s," (") = '\0';
    if( strchr(s, '(') ) *strchr(s, '(') = '\0';

    // convert case edges into spaces (@fixme: utf8 games; russian? spanish? czech?)
    // there x2 room for worst case: aBcDeF>a*B*c*D*e*F, and also extra room for further string patches
    char *spaced = memset(va("%*.s", strlen(s)*2+5, ""), 0, strlen(s)*2+5);
    for( int i = 0, j = 0; s[i]; ++i ) {
        if( i >= 1 ) {
            int upper = islower(s[i-1]) && isupper(s[i]);
            int digit = isalpha(s[i-1]) && isdigit(s[i]);
            if( upper || digit ) spaced[j++] = '*';
        }
        spaced[j++] = s[i];
    }
    s = spaced;

    // convert to uppercase (@fixme: utf8 games; russian? spanish? czech?)
    for( int i = 0; s[i]; ++i ) s[i] = toupper(s[i]);

    // trim lead/final spaces and double spaces
    while( s[0] == ' ' ) ++s;
    while( s[strlen(s)-1] == ' ' ) s[strlen(s)-1] = '\0';
    while( strstr(s, "  ") ) replace(s, "  ", " ");

    // reorder prefixes written as suffixes
    /**/ if( strendi(s, ", THE") ) memmove(s+4,s,strlen(s+4)), memcpy(s, "THE ", 4), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", LOS") ) memmove(s+4,s,strlen(s+4)), memcpy(s, "LOS ", 4), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", LAS") ) memmove(s+4,s,strlen(s+4)), memcpy(s, "LAS ", 4), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", EL")  ) memmove(s+3,s,strlen(s+3)), memcpy(s, "EL ", 3), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", LA")  ) memmove(s+3,s,strlen(s+3)), memcpy(s, "LA ", 3), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", IL")  ) memmove(s+3,s,strlen(s+3)), memcpy(s, "IL ", 3), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", A")   ) memmove(s+2,s,strlen(s+2)), memcpy(s, "A ", 2), s[strlen(s)-1] = '\0';
    else if( strendi(s, ", O")   ) memmove(s+2,s,strlen(s+2)), memcpy(s, "O ", 2), s[strlen(s)-1] = '\0';

#if 0
    // ignore title-subtitle separators
    replace(s, "_-_", "   ");
    replace(s, " - ", "   ");
#else
    // trim full title[:-,]subtitle separators
    // : 19 Part 1: Boot Camp|Fist II: The Legend Continues
    // - 1994 - Ten Years After|Plane Lazy - a steamboat spin-off
    // , Dizzy, Prince of the YolkFolk
    if( !full_form ) {
        replace(s, "_-_", "\1");
        replace(s, " - ", "\1");
        for( char *dash = strchr(s+1, '-'); dash; dash = 0) if( isdigit(dash[-1]) ) *dash = '\1'; // split Saboteur2-AvengingAngel but dont split Bat-Man
        replace(s, ",", "\1");
        replace(s, ":", "\1");
        for( int i = 0; s[i]; ++i ) if(s[i] == 1) s[i] = 0;
    }
        replace(s, "-", "*");
#endif

    // remove: gonzzalezz - side 1, Toi Acid Game - Side 2
    // remove: Outrun - Tape 2 - Side 1, 5_Exitos_De_Opera_Soft_Tape_1_-_Side_1,
    const char *tags[] = {
        " TAPE 1",
        " TAPE 2",
        " PART 1",
        " PART 2",
        " PART II",
        " SIDE 1",
        " SIDE 2",
        " SIDE A",
        " SIDE B",
        " SIDEA-V1",
        " SIDEA-V2",
        " SIDEB-V1",
        " SIDEB-V2",
        " RELEASE 1",
        " RELEASE 2",
        " RELEASE 3",
        " SMALL CASE",
        " SMALL CARDBOARD CASE",
        " MEDIUM CASE",
        " BUGFIX",
        " DEMONSTRATOR",
        " STANDARD",
        " EXPERT",
        0
    };
    for( int i = 0; tags[i]; ++i ) {
        if( strstr(s, tags[i]) ) {
            strstr(s, tags[i])[0] = '\0';
        }
    }

    // preserve common non-alpha chars:
    // ! Aaargh!
    // ' Ghouls 'n' Ghosts
    // . N.E.X.O.R.
    // * Q*Bert
    // & Olli & Lissa 3
    //
    // convert rest of non-alpha into spaces (@fixme: utf8 games; russian? spanish? czech?)
    //
    // $ Mega-Buck$
    // £ Wh££ler Dealer
    // ¡ ¡Asalto!
    // " Santa's Workshop - Chapter II "Harry Strikes Back"
    // # Manic Miner #2
    // ? Whodunnit?
    // + Exploding Fist +
    // > Depeche Mode: The Singles 81->85
    // _ Ala-Bala_Mutari
    // \ BATS'92\\1
    // / 50/50|Air/Sea Missile
    // [] Bingo [4]|Number 6 in the Village [QUILL]|Number 6 in the Village [PAW]
    // () Psycho (Eliza)|Welcome to Hell (ZxZvm)
    //
    for( int i = 0; s[i]; ++i ) s[i] = isalnum(s[i]) || strchr("!'.*&",s[i]) ? s[i] : '*';

    // final touch, remove 48 and 128 from filenames: renegade128.tzx, rasputin48.sna, etc
    if( strstr(s, "*48K") ) strstr(s, "*48K")[1] = '\0'; // memcpy(strstr(s, "*48K"), "****",  4);
    if( strstr(s,"*128K") ) strstr(s,"*128K")[1] = '\0'; // memcpy(strstr(s,"*128K"), "*****", 5);
    if( strstr(s,  "*48") ) strstr(s,  "*48")[1] = '\0'; // memcpy(strstr(s,  "*48"), "***",   3);
    if( strstr(s, "*128") ) strstr(s, "*128")[1] = '\0'; // memcpy(strstr(s, "*128"), "****",  4);

    return s;
}

zxdb zxdb_search(const char *id, int allow_multiple) { // game.tap or #13372
    // @todo: extract hints from filename (like publisher or year) to disambiguate multiple search hits.
    // See: Jaws(1984).tap vs Jaws(AlternativeSoftwareLtd).tzx

    // search by id. exclude #id[#seq...]
    if( id[0] == '#' ) return zxdb_search_by_name(va("#%d",atoi(id+1)), allow_multiple);

    zxdb z = {0};

    // try full `title:subtitle` form first, else try a short `title` form if available
    // see: FistII:TheLegendContinues does not have a short form in ZXDB (Fist II)
    // see: Saboteur2 does not have a long form in ZXDB (Saboteur 2: Avenging Angel)

    for( int full = 2; !z.ids[0] && --full >= 0; ) {
        // search by filename
        char *s = zxdb_filename2title(id, full);
        z = zxdb_search_by_name(s, allow_multiple);

        // if it fails, try again replacing trailing 2>"% II", 3>"% III", 4>"% IV", 5>"% V" and viceversa.
        // lines ago, we did pre-allocate room space for this patch.
        if( !z.ids[0] && strendi(s,   "*2") ) strcpy((char*)strendi(s,  "*2"), "* II"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,   "*2") ) memcpy((char*)strstri(s,  "*2"), "* II*", 5), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,   "*3") ) strcpy((char*)strendi(s,  "*3"),"* III"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,   "*3") ) memcpy((char*)strstri(s,  "*3"),"* III*", 6), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,   "*4") ) strcpy((char*)strendi(s,  "*4"), "* IV"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,   "*4") ) memcpy((char*)strstri(s,  "*4"), "* IV*", 5), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,   "*5") ) strcpy((char*)strendi(s,  "*5"),  "* V"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,   "*5") ) memcpy((char*)strstri(s,  "*5"),  "* V*", 4), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s, "*III") ) strcpy((char*)strendi(s,"*III"),  "* 3"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s, "*III") ) memcpy((char*)strstri(s,"*III"),  "* 3*", 4), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,  "*II") ) strcpy((char*)strendi(s, "*II"),  "* 2"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,  "*II") ) memcpy((char*)strstri(s, "*II"),  "* 2*", 4), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,  "*IV") ) strcpy((char*)strendi(s, "*IV"),  "* 4"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,  "*IV") ) memcpy((char*)strstri(s, "*IV"),  "* 4*", 4), z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strendi(s,   "*V") ) strcpy((char*)strendi(s,  "*V"),  "* 5"),     z = zxdb_search_by_name(s, allow_multiple);
        if( !z.ids[0] && strstri(s,   "*V") ) memcpy((char*)strstri(s,  "*V"),  "* 5*", 4), z = zxdb_search_by_name(s, allow_multiple);
    }

    return z;
}

char* zxdb_url(const zxdb z, const char *hint) {

    if( isdigit(hint[0]) ) {
        int chosen = atoi(hint);
        for( int i = 0; z.downloads[i]; ++i ) {
            if( i == chosen ) {
                char *url = va("%.*s", strchr(z.downloads[i],'|') - z.downloads[i], z.downloads[i]);
                // if( /*strstri(url, ".szx") ||*/ strstri(url, ".slt") ) continue;
                return url;
            }
        }
        return NULL;
    }

    if( strstri(hint, "play") ) {
        char *media = 0;
        if(!media) media = zxdb_url(z, "bugfix");
        if(!media) media = zxdb_url(z, "tape");
        if(!media) media = zxdb_url(z, "disk");
        if(!media) media = zxdb_url(z, "rom");
        if(!media) media = zxdb_url(z, "snap");
        return media;
    }

    int check_media = 0;

    /**/ if( strstri(hint, "runn") ) hint = "|2|R"; // .scr, .gif, .png, .jpg, .mc/.mlt, .ifl (multicolor8x2 9216 = 6144+768*4)
    else if( strstri(hint, "screen") ) hint = "|1|L"; // .scr, .gif, .png, .jpg, .mc/.mlt (multicolor8x1 12288 = 6144+768*8)
    else if( strstri(hint, "inlay") && strstri(hint, "side")) hint = "|4|I";
    else if( strstri(hint, "inlay") && strstri(hint, "back")) hint = "|6|I";
    else if( strstri(hint, "inlay")) hint = "|5|I";
    else if( strstri(hint, "bugfix")) hint = "|22|B", check_media = 1;
    else if( strstri(hint, "tape")) hint = "|8|T", check_media = 1;
    else if( strstri(hint, "snap")) hint = "|10|S", check_media = 1;
    else if( strstri(hint, "disk")) hint = "|11|D", check_media = 1;
    else if( strstri(hint, "rom")) hint = "|17|C", check_media = 1;
    else if( strstri(hint, "mp3")) hint = "|27|B";
    else if( strstri(hint, "scanned")) hint = "|29|S";
    else if( strstri(hint, "instr")) hint = "|28|I";
    else if( strstri(hint, "overlay")) hint = "|30|K"; // @todo: addme
    else if( strstri(hint, "ay")) hint = "|23|R"; // after overlay
    else if( strstri(hint, "png|map")) hint = "png|0|31|G";
    else if( strstri(hint, "map")) hint = "|31|G"; // after png|map
    else if( strstri(hint, "comic")) hint = "|59|C";
    else if( strstri(hint, "rzx")) hint = "|63|R";
    else if( strstri(hint, "pok")) hint = "|74|P";

    for( int i = 0; z.downloads[i]; ++i ) {
        if( strstri(z.downloads[i], hint) ) {
            char slot[6]; snprintf(slot, 6-1, "%d", i);
            return zxdb_url(z, slot);
        }
    }

    return NULL;
}

static
char* zxdb_download_(const char *url, int *len) {
    if( !url ) return 0;

    if( url[0] == '/' ) {
        const char *mirror = 0;
        /**/ if( !strncmp(url, "/nvg/", 5) ) {
            mirror = "https://archive.org/download/mirror-ftp-nvg/Mirror_ftp_nvg.zip/";
            url += 5;
        }
        else if( !strncmp(url, "/pub/", 5) ) {
            // ok, redirection
            // mirror = "https://worldofspectrum.net/"; // may be outdated
            // mirror = "https://worldofspectrum.org/"; // 200.00 MB Downloads Available
            mirror = "https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/";
            url += 5;
        }
        else if( !strncmp(url, "/zxdb/", 6) ) {
            //mirror = "https://spectrumcomputing.co.uk/"; // ok
            mirror = "https://zxinfo.dk/media/"; // ok
            url += 1;
        }
        else {
            // try https:// as a fallback in case it is a full url
            mirror = "https://";
            url += 1;
        }
        return mirror ? download(va("%s%s", mirror, url), len) : 0;
    }
    // puts(url);
    return download(url, len);
}

char* zxdb_download(const zxdb z, const char *url, int *len) {
    if( !url ) return NULL;

    const char *id = z.ids[0];
    const char *title = z.ids[2];
    char *roman = romanize(title);
#if 0
    replace(roman, " ", "");
    replace(roman, ".", "");
    replace(roman, "?", "");
    replace(roman, "*", "");
    replace(roman, "`", "");
    replace(roman, "'", "");
    replace(roman, "\"","");
    replace(roman, "&", "N");
    replace(roman, "^", "-");
    replace(roman, "|", "-");
    replace(roman, "/", "-");
    replace(roman, "\\","-");
    replace(roman, ":", "-");
    replace(roman, ";", "-");
    replace(roman, "<", "-");
    replace(roman, ">", "-");
#else
    replace(roman, " ", "");
    replace(roman, ".", "");
    replace(roman, "`", "");
    replace(roman, "'", "");
    replace(roman, "\"","");
    replace(roman, ":", "-");
    for( int i = 0; roman[i]; ++i )
        if( roman[i] < 'a' || roman[i] > 'z')
            if( roman[i] < 'A' || roman[i] > 'Z')
                if( roman[i] < '0' || roman[i] > '9')
                    if( roman[i] < '(' || roman[i] > ')')
                        roman[i] = '-';
    do replace(roman, "--", "-"); while(strstr(roman,"--"));
#endif

    char index = toupper(roman[0]);
    if( index < 'A' || index > 'Z' ) index = '#';

    // convert /url/basename to /path/file.zip/basename
    char path[128];
    do_once mkdir(".Spectral", 0777);
    snprintf(path, 128, ".Spectral/%c", index); mkdir(path, 0777);
    snprintf(path, 128, ".Spectral/%c/%s[%s].zip/%s", index, roman, id, basename(url));

    char *cache = unzip(path, len);
    if( !cache ) {
        cache = zxdb_download_(url, len);
        if( cache ) {
#if 1
            // do not cache file contents that start with '<!' or end with '/>'
            // they are probably http server errors (403,404,500,...) and we do not
            // download html files in any case.
            // @fixme: try to catch http server errors within our download() function instead
            bool is_html = 0;
            if( *len > 2 && cache[0] == '<' && cache[1] == '!' ) is_html |= 1;
            if( is_html ) return free(cache), NULL; // unlink(path), cache = 0;
#endif
            zipme(path, cache, *len);
        }
    }
    return cache;
}

int zxdb_model(const zxdb z) {
    if( z.ids[0] ) {
        char *model = strchr(z.ids[5], ',')+1;
        /**/ if( strstr(model, "Pentagon") ) return 129;
        else if( strstr(model, "+3") )       return 300;
        else if( strstr(model, "+2A") )      return 210;
        else if( strstr(model, "+2B") )      return 210;
        else if( strstr(model, "+2") )       return 200;
        else if( strstr(model, "USR0") )     return 128; // @fixme: catch this case
        else if( strstr(model, "48K/128K") ) return 128; // educated guess. cannot infer
        else if( strstr(model, "128") )      return 128;
        else if( strstr(model, "48") )       return 48;
        else if( strstr(model, "16") )       return 16;
    }
    return 0;
}
