/* jslint node: true */
'use strict';

const fileDb = require('./database.js').dbs.file;
const Errors = require('./enig_error.js').Errors;
const { getISOTimestampString, sanitizeString } = require('./database.js');
const Config = require('./config.js').get;

//  deps
const async = require('async');
const _ = require('lodash');
const paths = require('path');
const fse = require('fs-extra');
const { unlink, readFile } = require('graceful-fs');
const crypto = require('crypto');
const moment = require('moment');

const FILE_TABLE_MEMBERS = [
    'file_id',
    'area_tag',
    'file_sha256',
    'file_name',
    'storage_tag',
    'desc',
    'desc_long',
    'upload_timestamp',
];

const FILE_WELL_KNOWN_META = {
    //  name -> *read* converter, if any
    upload_by_username: null,
    upload_by_user_id: u => parseInt(u) || 0,
    file_md5: null,
    file_sha1: null,
    file_crc32: null,
    est_release_year: y => parseInt(y) || new Date().getFullYear(),
    dl_count: d => parseInt(d) || 0,
    byte_size: b => parseInt(b) || 0,
    archive_type: null,
    short_file_name: null, //  e.g. DOS 8.3 filename, avail in some scenarios such as TIC import
    tic_origin: null, //  TIC "Origin"
    tic_desc: null, //  TIC "Desc"
    tic_ldesc: null, //  TIC "Ldesc" joined by '\n'
    session_temp_dl: v => (parseInt(v) ? true : false),
    desc_sauce: s => JSON.parse(s) || {},
    desc_long_sauce: s => JSON.parse(s) || {},
};

module.exports = class FileEntry {
    constructor(options) {
        options = options || {};

        this.fileId = options.fileId || 0;
        this.areaTag = options.areaTag || '';
        this.meta = Object.assign({ dl_count: 0 }, options.meta);
        this.hashTags = options.hashTags || new Set();
        this.fileName = options.fileName;
        this.storageTag = options.storageTag;
        this.fileSha256 = options.fileSha256;
    }

    static loadBasicEntry(fileId, dest, cb) {
        dest = dest || {};

        fileDb.get(
            `SELECT ${FILE_TABLE_MEMBERS.join(', ')}
            FROM file
            WHERE file_id=?
            LIMIT 1;`,
            [fileId],
            (err, file) => {
                if (err) {
                    return cb(err);
                }

                if (!file) {
                    return cb(Errors.DoesNotExist('No file is available by that ID'));
                }

                //  assign props from |file|
                FILE_TABLE_MEMBERS.forEach(prop => {
                    dest[_.camelCase(prop)] = file[prop];
                });

                return cb(null, dest);
            }
        );
    }

    load(fileId, cb) {
        const self = this;

        async.series(
            [
                function loadBasicEntry(callback) {
                    FileEntry.loadBasicEntry(fileId, self, callback);
                },
                function loadMeta(callback) {
                    return self.loadMeta(callback);
                },
                function loadHashTags(callback) {
                    return self.loadHashTags(callback);
                },
                function loadUserRating(callback) {
                    return self.loadRating(callback);
                },
            ],
            err => {
                return cb(err);
            }
        );
    }

    persist(isUpdate, cb) {
        if (!cb && _.isFunction(isUpdate)) {
            cb = isUpdate;
            isUpdate = false;
        }

        const self = this;

        async.waterfall(
            [
                function check(callback) {
                    if (isUpdate && !self.fileId) {
                        return callback(
                            Errors.Invalid(
                                'Cannot update file entry without an existing "fileId" member'
                            )
                        );
                    }
                    return callback(null);
                },
                function calcSha256IfNeeded(callback) {
                    if (self.fileSha256) {
                        return callback(null);
                    }

                    if (isUpdate) {
                        return callback(
                            Errors.MissingParam(
                                'fileSha256 property must be set for updates!'
                            )
                        );
                    }

                    readFile(self.filePath, (err, data) => {
                        if (err) {
                            return callback(err);
                        }

                        const sha256 = crypto.createHash('sha256');
                        sha256.update(data);
                        self.fileSha256 = sha256.digest('hex');
                        return callback(null);
                    });
                },
                function startTrans(callback) {
                    return fileDb.beginTransaction(callback);
                },
                function storeEntry(trans, callback) {
                    if (isUpdate) {
                        trans.run(
                            `REPLACE INTO file (file_id, area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp)
                            VALUES(?, ?, ?, ?, ?, ?, ?, ?);`,
                            [
                                self.fileId,
                                self.areaTag,
                                self.fileSha256,
                                self.fileName,
                                self.storageTag,
                                self.desc,
                                self.descLong,
                                getISOTimestampString(),
                            ],
                            err => {
                                return callback(err, trans);
                            }
                        );
                    } else {
                        trans.run(
                            `REPLACE INTO file (area_tag, file_sha256, file_name, storage_tag, desc, desc_long, upload_timestamp)
                            VALUES(?, ?, ?, ?, ?, ?, ?);`,
                            [
                                self.areaTag,
                                self.fileSha256,
                                self.fileName,
                                self.storageTag,
                                self.desc,
                                self.descLong,
                                getISOTimestampString(),
                            ],
                            function inserted(err) {
                                //  use non-arrow func for 'this' scope / lastID
                                if (!err) {
                                    self.fileId = this.lastID;
                                }
                                return callback(err, trans);
                            }
                        );
                    }
                },
                function storeMeta(trans, callback) {
                    async.each(
                        Object.keys(self.meta),
                        (n, next) => {
                            const v = self.meta[n];
                            return FileEntry.persistMetaValue(
                                self.fileId,
                                n,
                                v,
                                trans,
                                next
                            );
                        },
                        err => {
                            return callback(err, trans);
                        }
                    );
                },
                function storeHashTags(trans, callback) {
                    const hashTagsArray = Array.from(self.hashTags);
                    async.each(
                        hashTagsArray,
                        (hashTag, next) => {
                            return FileEntry.persistHashTag(
                                self.fileId,
                                hashTag,
                                trans,
                                next
                            );
                        },
                        err => {
                            return callback(err, trans);
                        }
                    );
                },
            ],
            (err, trans) => {
                //  :TODO: Log orig err
                if (trans) {
                    trans[err ? 'rollback' : 'commit'](transErr => {
                        return cb(transErr ? transErr : err);
                    });
                } else {
                    return cb(err);
                }
            }
        );
    }

    static getAreaStorageDirectoryByTag(storageTag) {
        const config = Config();
        const storageLocation = storageTag && config.fileBase.storageTags[storageTag];

        //  absolute paths as-is
        if (storageLocation && '/' === storageLocation.charAt(0)) {
            return storageLocation;
        }

        //  relative to |areaStoragePrefix|
        return paths.join(config.fileBase.areaStoragePrefix, storageLocation || '');
    }

    get filePath() {
        const storageDir = FileEntry.getAreaStorageDirectoryByTag(this.storageTag);
        return paths.join(storageDir, this.fileName);
    }

    static quickCheckExistsByPath(fullPath, cb) {
        fileDb.get(
            `SELECT COUNT() AS count
            FROM file
            WHERE file_name = ?
            LIMIT 1;`,
            [paths.basename(fullPath)],
            (err, rows) => {
                return err ? cb(err) : cb(null, rows.count > 0 ? true : false);
            }
        );
    }

    static persistUserRating(fileId, userId, rating, cb) {
        return fileDb.run(
            `REPLACE INTO file_user_rating (file_id, user_id, rating)
            VALUES (?, ?, ?);`,
            [fileId, userId, rating],
            cb
        );
    }

    static removeUserRatings(userId, cb) {
        return fileDb.run(
            `DELETE FROM file_user_rating
            WHERE user_id = ?;`,
            [userId],
            cb
        );
    }

    static persistMetaValue(fileId, name, value, transOrDb, cb) {
        if (!_.isFunction(cb) && _.isFunction(transOrDb)) {
            cb = transOrDb;
            transOrDb = fileDb;
        }

        return transOrDb.run(
            `REPLACE INTO file_meta (file_id, meta_name, meta_value)
            VALUES (?, ?, ?);`,
            [fileId, name, value],
            cb
        );
    }

    static incrementAndPersistMetaValue(fileId, name, incrementBy, cb) {
        incrementBy = incrementBy || 1;
        fileDb.run(
            `UPDATE file_meta
            SET meta_value = meta_value + ?
            WHERE file_id = ? AND meta_name = ?;`,
            [incrementBy, fileId, name],
            err => {
                if (cb) {
                    return cb(err);
                }
            }
        );
    }

    loadMeta(cb) {
        fileDb.each(
            `SELECT meta_name, meta_value
            FROM file_meta
            WHERE file_id=?;`,
            [this.fileId],
            (err, meta) => {
                if (meta) {
                    const conv = FILE_WELL_KNOWN_META[meta.meta_name];
                    this.meta[meta.meta_name] = conv
                        ? conv(meta.meta_value)
                        : meta.meta_value;
                }
            },
            err => {
                return cb(err);
            }
        );
    }

    static persistHashTag(fileId, hashTag, transOrDb, cb) {
        if (!_.isFunction(cb) && _.isFunction(transOrDb)) {
            cb = transOrDb;
            transOrDb = fileDb;
        }

        transOrDb.serialize(() => {
            transOrDb.run(
                `INSERT OR IGNORE INTO hash_tag (hash_tag)
                VALUES (?);`,
                [hashTag]
            );

            transOrDb.run(
                `REPLACE INTO file_hash_tag (hash_tag_id, file_id)
                VALUES (
                    (SELECT hash_tag_id
                    FROM hash_tag
                    WHERE hash_tag = ?),
                    ?
                );`,
                [hashTag, fileId],
                err => {
                    return cb(err);
                }
            );
        });
    }

    loadHashTags(cb) {
        fileDb.each(
            `SELECT ht.hash_tag_id, ht.hash_tag
            FROM hash_tag ht
            WHERE ht.hash_tag_id IN (
                SELECT hash_tag_id
                FROM file_hash_tag
                WHERE file_id=?
            );`,
            [this.fileId],
            (err, hashTag) => {
                if (hashTag) {
                    this.hashTags.add(hashTag.hash_tag);
                }
            },
            err => {
                return cb(err);
            }
        );
    }

    loadRating(cb) {
        fileDb.get(
            `SELECT AVG(fur.rating) AS avg_rating
            FROM file_user_rating fur
            INNER JOIN file f
                ON f.file_id = fur.file_id
                AND f.file_id = ?`,
            [this.fileId],
            (err, result) => {
                if (result) {
                    this.userRating = result.avg_rating;
                }
                return cb(err);
            }
        );
    }

    setHashTags(hashTags) {
        if (_.isString(hashTags)) {
            this.hashTags = new Set(hashTags.split(/[\s,]+/));
        } else if (Array.isArray(hashTags)) {
            this.hashTags = new Set(hashTags);
        } else if (hashTags instanceof Set) {
            this.hashTags = hashTags;
        }
    }

    static get WellKnownMetaValues() {
        return Object.keys(FILE_WELL_KNOWN_META);
    }

    static getFileIdsBySha(sha, options = {}, cb) {
        //  full or partial SHA-256
        const limit = _.isNumber(options.limit) ? `LIMIT ${options.limit}` : '';
        fileDb.all(
            `SELECT file_id
            FROM file
            WHERE file_sha256 LIKE "${sha}%" ${limit};`,
            (err, fileIdRows) => {
                if (err) {
                    return cb(err);
                }

                return cb(
                    null,
                    (fileIdRows || []).map(r => r.file_id)
                );
            }
        );
    }

    static findBySha(sha, cb) {
        FileEntry.getFileIdsBySha(sha, { limit: 2 }, (err, fileIds) => {
            if (err) {
                return cb(err);
            }

            if (!fileIds || 0 === fileIds.length) {
                return cb(Errors.DoesNotExist('No matches'));
            }

            if (fileIds.length > 1) {
                return cb(Errors.Invalid('SHA is ambiguous'));
            }

            const fileEntry = new FileEntry();
            return fileEntry.load(fileIds[0], err => {
                return cb(err, fileEntry);
            });
        });
    }

    //  Attempt to fine a file by an *existing* full path.
    //  Checkums may have changed and are not validated here.
    static findByFullPath(fullPath, cb) {
        //  first, basic by-filename lookup.
        FileEntry.findByFileNameWildcard(paths.basename(fullPath), (err, entries) => {
            if (err) {
                return cb(err);
            }
            if (!entries || !entries.length || entries.length > 1) {
                return cb(Errors.DoesNotExist('No matches'));
            }

            //  ensure the *full* path has not changed
            //  :TODO: if FS is case-insensitive, we probably want a better check here
            const possibleMatch = entries[0];
            if (possibleMatch.fullPath === fullPath) {
                return cb(null, possibleMatch);
            }

            return cb(Errors.DoesNotExist('No matches'));
        });
    }

    static findByFileNameWildcard(wc, cb) {
        //  convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
        wc = wc.replace(/\*/g, '%').replace(/\?/g, '_');

        fileDb.all(
            `SELECT file_id
            FROM file
            WHERE file_name LIKE "${wc}"
            `,
            (err, fileIdRows) => {
                if (err) {
                    return cb(err);
                }

                if (!fileIdRows || 0 === fileIdRows.length) {
                    return cb(Errors.DoesNotExist('No matches'));
                }

                const entries = [];
                async.each(
                    fileIdRows,
                    (row, nextRow) => {
                        const fileEntry = new FileEntry();
                        fileEntry.load(row.file_id, err => {
                            if (!err) {
                                entries.push(fileEntry);
                            }
                            return nextRow(err);
                        });
                    },
                    err => {
                        return cb(err, entries);
                    }
                );
            }
        );
    }

    //
    //  Find file(s) by |filter|
    //
    //  - sort: sort results by any well known name, file_id, or user_rating
    //  - terms: one or more search terms to search within filenames as well
    //    as short and long descriptions. We attempt to use the FTS ability when
    //    possible, but want to allow users to search for wildcard matches in
    //    which some cases we'll use multiple LIKE queries.
    //    See _normalizeFileSearchTerms()
    //
    static findFiles(filter, cb) {
        filter = filter || {};

        let sql;
        let sqlWhere = '';
        let sqlOrderBy;
        const sqlOrderDir = 'ascending' === filter.order ? 'ASC' : 'DESC';

        if (moment.isMoment(filter.newerThanTimestamp)) {
            filter.newerThanTimestamp = getISOTimestampString(filter.newerThanTimestamp);
        }

        function getOrderByWithCast(ob) {
            if (['dl_count', 'est_release_year', 'byte_size'].indexOf(filter.sort) > -1) {
                return `ORDER BY CAST(${ob} AS INTEGER)`;
            }

            return `ORDER BY ${ob}`;
        }

        function appendWhereClause(clause) {
            if (sqlWhere) {
                sqlWhere += ' AND ';
            } else {
                sqlWhere += ' WHERE ';
            }
            sqlWhere += clause;
        }

        if (filter.sort && filter.sort.length > 0) {
            if (Object.keys(FILE_WELL_KNOWN_META).indexOf(filter.sort) > -1) {
                //  sorting via a meta value?
                sql = `SELECT DISTINCT f.file_id
                    FROM file f, file_meta m`;

                appendWhereClause(
                    `f.file_id = m.file_id AND m.meta_name = "${filter.sort}"`
                );

                sqlOrderBy = `${getOrderByWithCast('m.meta_value')} ${sqlOrderDir}`;
            } else {
                //  additional special treatment for user ratings: we need to average them
                if ('user_rating' === filter.sort) {
                    sql = `SELECT DISTINCT f.file_id,
                            (SELECT IFNULL(AVG(rating), 0) rating
                            FROM file_user_rating
                            WHERE file_id = f.file_id)
                            AS avg_rating
                        FROM file f`;

                    sqlOrderBy = `ORDER BY avg_rating ${sqlOrderDir}`;
                } else {
                    sql = `SELECT DISTINCT f.file_id
                        FROM file f`;

                    sqlOrderBy =
                        getOrderByWithCast(`f.${filter.sort}`) + ' ' + sqlOrderDir;
                }
            }
        } else {
            sql = `SELECT DISTINCT f.file_id
                FROM file f`;

            sqlOrderBy = `${getOrderByWithCast('f.file_id')} ${sqlOrderDir}`;
        }

        if (filter.areaTag && filter.areaTag.length > 0) {
            if (Array.isArray(filter.areaTag)) {
                const areaList = filter.areaTag.map(t => `"${t}"`).join(', ');
                appendWhereClause(`f.area_tag IN(${areaList})`);
            } else {
                appendWhereClause(`f.area_tag = "${filter.areaTag}"`);
            }
        }

        if (filter.metaPairs && filter.metaPairs.length > 0) {
            filter.metaPairs.forEach(mp => {
                if (mp.wildcards) {
                    //  convert any * -> % and ? -> _ for SQLite syntax - see https://www.sqlite.org/lang_expr.html
                    mp.value = mp.value.replace(/\*/g, '%').replace(/\?/g, '_');
                    appendWhereClause(
                        `f.file_id IN (
                            SELECT file_id
                            FROM file_meta
                            WHERE meta_name = "${mp.name}" AND meta_value LIKE "${mp.value}"
                        )`
                    );
                } else {
                    appendWhereClause(
                        `f.file_id IN (
                            SELECT file_id
                            FROM file_meta
                            WHERE meta_name = "${mp.name}" AND meta_value = "${mp.value}"
                        )`
                    );
                }
            });
        }

        if (filter.storageTag && filter.storageTag.length > 0) {
            appendWhereClause(`f.storage_tag="${filter.storageTag}"`);
        }

        if (filter.terms && filter.terms.length > 0) {
            const [terms, queryType] = FileEntry._normalizeFileSearchTerms(filter.terms);

            if ('fts_match' === queryType) {
                //  note the ':' in MATCH expr., see https://www.sqlite.org/cvstrac/wiki?p=FullTextIndex
                appendWhereClause(
                    `f.file_id IN (
                        SELECT rowid
                        FROM file_fts
                        WHERE file_fts MATCH ":${terms}"
                    )`
                );
            } else {
                appendWhereClause(
                    `(f.file_name LIKE "${terms}" OR
                    f.desc LIKE "${terms}" OR
                    f.desc_long LIKE "${terms}")`
                );
            }
        }

        if (_.isString(filter.fileName) && filter.fileName.length > 0) {
            const caseSensitive = _.get(filter, 'filenameCaseSensitive', false);
            const collate = caseSensitive ? '' : 'COLLATE NOCASE';
            appendWhereClause(
                `(f.file_name = "${sanitizeString(filter.fileName)}" ${collate})`
            );
        }

        //  handle e.g. 1998 -> "1998"
        if (_.isNumber(filter.tags)) {
            filter.tags = filter.tags.toString();
        }

        if (filter.tags && filter.tags.length > 0) {
            //  build list of quoted tags; filter.tags comes in as a space and/or comma separated values
            const tags = filter.tags
                .replace(/,/g, ' ')
                .replace(/\s{2,}/g, ' ')
                .split(' ')
                .map(tag => `"${sanitizeString(tag)}"`)
                .join(',');

            appendWhereClause(
                `f.file_id IN (
                    SELECT file_id
                    FROM file_hash_tag
                    WHERE hash_tag_id IN (
                        SELECT hash_tag_id
                        FROM hash_tag
                        WHERE hash_tag IN (${tags})
                    )
                )`
            );
        }

        if (
            _.isString(filter.newerThanTimestamp) &&
            filter.newerThanTimestamp.length > 0
        ) {
            appendWhereClause(
                `DATETIME(f.upload_timestamp) > DATETIME("${filter.newerThanTimestamp}", "+1 seconds")`
            );
        }

        if (_.isNumber(filter.newerThanFileId)) {
            appendWhereClause(`f.file_id > ${filter.newerThanFileId}`);
        }

        sql += `${sqlWhere} ${sqlOrderBy}`;

        if (_.isNumber(filter.limit)) {
            sql += ` LIMIT ${filter.limit}`;
        }

        sql += ';';

        fileDb.all(sql, (err, rows) => {
            if (err) {
                return cb(err);
            }
            if (!rows || 0 === rows.length) {
                return cb(null, []); //  no matches
            }
            return cb(
                null,
                rows.map(r => r.file_id)
            );
        });
    }

    static removeEntry(srcFileEntry, options, cb) {
        if (!_.isFunction(cb) && _.isFunction(options)) {
            cb = options;
            options = {};
        }

        async.series(
            [
                function removeFromDatabase(callback) {
                    fileDb.run(
                        `DELETE FROM file
                        WHERE file_id = ?;`,
                        [srcFileEntry.fileId],
                        err => {
                            return callback(err);
                        }
                    );
                },
                function optionallyRemovePhysicalFile(callback) {
                    if (true !== options.removePhysFile) {
                        return callback(null);
                    }

                    unlink(srcFileEntry.filePath, err => {
                        return callback(err);
                    });
                },
            ],
            err => {
                return cb(err);
            }
        );
    }

    static moveEntry(srcFileEntry, destAreaTag, destStorageTag, destFileName, cb) {
        if (!cb && _.isFunction(destFileName)) {
            cb = destFileName;
            destFileName = srcFileEntry.fileName;
        }

        const srcPath = srcFileEntry.filePath;
        const dstDir = FileEntry.getAreaStorageDirectoryByTag(destStorageTag);

        if (!dstDir) {
            return cb(Errors.Invalid('Invalid storage tag'));
        }

        const dstPath = paths.join(dstDir, destFileName);

        async.series(
            [
                function movePhysFile(callback) {
                    if (srcPath === dstPath) {
                        return callback(null); //  don't need to move file, but may change areas
                    }

                    fse.move(srcPath, dstPath, err => {
                        return callback(err);
                    });
                },
                function updateDatabase(callback) {
                    fileDb.run(
                        `UPDATE file
                        SET area_tag = ?, file_name = ?, storage_tag = ?
                        WHERE file_id = ?;`,
                        [destAreaTag, destFileName, destStorageTag, srcFileEntry.fileId],
                        err => {
                            return callback(err);
                        }
                    );
                },
            ],
            err => {
                return cb(err);
            }
        );
    }

    static _normalizeFileSearchTerms(terms) {
        //  ensure we have reasonable input to start with
        terms = sanitizeString(terms.toString());

        //	No wildcards?
        const hasSingleCharWC = terms.indexOf('?') > -1;
        if (terms.indexOf('*') === -1 && !hasSingleCharWC) {
            return [terms, 'fts_match'];
        }

        const prepareLike = () => {
            //	Convert * and ? to SQL LIKE style
            terms = terms.replace(/\*/g, '%').replace(/\?/g, '_');
            return terms;
        };

        //	Any ? wildcards?
        if (hasSingleCharWC) {
            return [prepareLike(terms), 'like'];
        }

        const split = terms.replace(/\s+/g, ' ').split(' ');
        const useLike = split.some(term => {
            if (term.indexOf('?') > -1) {
                return true;
            }

            const wcPos = term.indexOf('*');
            if (wcPos > -1 && wcPos !== term.length - 1) {
                return true;
            }

            return false;
        });

        if (useLike) {
            return [prepareLike(terms), 'like'];
        }

        return [terms, 'fts_match'];
    }
};
