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

//  enigma-bbs
const MenuModule = require('./menu_module.js').MenuModule;
const stringFormat = require('./string_format.js');
const getSortedAvailableFileAreas =
    require('./file_base_area.js').getSortedAvailableFileAreas;
const getAreaDefaultStorageDirectory =
    require('./file_base_area.js').getAreaDefaultStorageDirectory;
const scanFile = require('./file_base_area.js').scanFile;
const getFileAreaByTag = require('./file_base_area.js').getFileAreaByTag;
const getDescFromFileName = require('./file_base_area.js').getDescFromFileName;
const ansiGoto = require('./ansi_term.js').goto;
const moveFileWithCollisionHandling =
    require('./file_util.js').moveFileWithCollisionHandling;
const pathWithTerminatingSeparator =
    require('./file_util.js').pathWithTerminatingSeparator;
const Log = require('./logger.js').log;
const Errors = require('./enig_error.js').Errors;
const FileEntry = require('./file_entry.js');
const isAnsi = require('./string_util.js').isAnsi;
const Events = require('./events.js');

//  deps
const async = require('async');
const _ = require('lodash');
const temptmp = require('temptmp').createTrackedSession('upload');
const paths = require('path');
const sanatizeFilename = require('sanitize-filename');

exports.moduleInfo = {
    name: 'Upload',
    desc: 'Module for classic file uploads',
    author: 'NuSkooler',
};

const FormIds = {
    options: 0,
    processing: 1,
    fileDetails: 2,
    dupes: 3,
};

const MciViewIds = {
    options: {
        area: 1, //  area selection
        uploadType: 2, //  blind vs specify filename
        fileName: 3, //  for non-blind; not editable for blind
        navMenu: 4, //  next/cancel/etc.
        errMsg: 5, //  errors (e.g. filename cannot be blank)
    },

    processing: {
        calcHashIndicator: 1,
        archiveListIndicator: 2,
        descFileIndicator: 3,
        logStep: 4,
        customRangeStart: 10, //  10+ = customs
    },

    fileDetails: {
        desc: 1, //  defaults to 'desc' (e.g. from FILE_ID.DIZ)
        tags: 2, //  tag(s) for item
        estYear: 3,
        accept: 4, //  accept fields & continue
        customRangeStart: 10, //  10+ = customs
    },

    dupes: {
        dupeList: 1,
    },
};

exports.getModule = class UploadModule extends MenuModule {
    constructor(options) {
        super(options);

        this.interrupt = MenuModule.InterruptTypes.Never;

        if (_.has(options, 'lastMenuResult.recvFilePaths')) {
            this.recvFilePaths = options.lastMenuResult.recvFilePaths;
        }

        this.availAreas = getSortedAvailableFileAreas(this.client, { writeAcs: true });

        this.menuMethods = {
            optionsNavContinue: (formData, extraArgs, cb) => {
                return this.performUpload(cb);
            },

            fileDetailsContinue: (formData, extraArgs, cb) => {
                //  see displayFileDetailsPageForUploadEntry() for this hackery:
                cb(null);
                return this.fileDetailsCurrentEntrySubmitCallback(null, formData.value); //  move on to the next entry, if any
            },

            //  validation
            validateNonBlindFileName: (fileName, cb) => {
                if (0 === fileName.length) {
                    return cb(new Error('Filename cannot be empty'));
                }

                fileName = sanatizeFilename(fileName); //  remove unsafe chars, path info, etc.
                if (0 === fileName.length) {
                    //  sanatize nuked everything?
                    return cb(new Error('Invalid filename'));
                }

                //  At least SEXYZ doesn't like non-blind names that start with a number - it becomes confused ;-(
                if (/^[0-9].*$/.test(fileName)) {
                    return cb(new Error('Invalid filename'));
                }

                return cb(null);
            },
            viewValidationListener: (err, cb) => {
                const errView = this.viewControllers.options.getView(
                    MciViewIds.options.errMsg
                );
                if (errView) {
                    if (err) {
                        errView.setText(err.message);
                    } else {
                        errView.clearText();
                    }
                }

                return cb(null);
            },
        };
    }

    getSaveState() {
        //  if no areas, we're falling back due to lack of access/areas avail to upload to
        if (this.availAreas.length > 0 && this.viewControllers.options) {
            return {
                uploadType: this.uploadType,
                tempRecvDirectory: this.tempRecvDirectory,
                areaInfo:
                    this.availAreas[
                        this.viewControllers.options
                            .getView(MciViewIds.options.area)
                            .getData()
                    ],
            };
        }
    }

    restoreSavedState(savedState) {
        if (savedState.areaInfo) {
            this.uploadType = savedState.uploadType;
            this.areaInfo = savedState.areaInfo;
            this.tempRecvDirectory = savedState.tempRecvDirectory;
        }
    }

    isBlindUpload() {
        return 'blind' === this.uploadType;
    }
    isFileTransferComplete() {
        return !_.isUndefined(this.recvFilePaths);
    }

    initSequence() {
        const self = this;

        if (0 === this.availAreas.length) {
            //
            return this.gotoMenu(
                this.menuConfig.config.noUploadAreasAvailMenu ||
                    'fileBaseNoUploadAreasAvail'
            );
        }

        async.series(
            [
                function before(callback) {
                    return self.beforeArt(callback);
                },
                function display(callback) {
                    if (self.isFileTransferComplete()) {
                        return self.displayProcessingPage(callback);
                    } else {
                        return self.displayOptionsPage(callback);
                    }
                },
            ],
            () => {
                return self.finishedLoading();
            }
        );
    }

    finishedLoading() {
        if (this.isFileTransferComplete()) {
            return this.processUploadedFiles();
        }
    }

    performUpload(cb) {
        temptmp.mkdir({ prefix: 'enigul-' }, (err, tempRecvDirectory) => {
            if (err) {
                return cb(err);
            }

            //  need a terminator for various external protocols
            this.tempRecvDirectory = pathWithTerminatingSeparator(tempRecvDirectory);

            const modOpts = {
                extraArgs: {
                    recvDirectory: this.tempRecvDirectory, //  we'll move files from here to their area container once processed/confirmed
                    direction: 'recv',
                },
            };

            if (!this.isBlindUpload()) {
                //  data has been sanatized at this point
                modOpts.extraArgs.recvFileName = this.viewControllers.options
                    .getView(MciViewIds.options.fileName)
                    .getData();
            }

            //
            //  Move along to protocol selection -> file transfer
            //  Upon completion, we'll re-enter the module with some file paths handed to us
            //
            return this.gotoMenu(
                this.menuConfig.config.fileTransferProtocolSelection ||
                    'fileTransferProtocolSelection',
                modOpts,
                cb
            );
        });
    }

    continueNonBlindUpload(cb) {
        return cb(null);
    }

    updateScanStepInfoViews(stepInfo) {
        //  :TODO: add some blinking (e.g. toggle items) indicators - see OBV.DOC

        const fmtObj = Object.assign({}, stepInfo);
        let stepIndicatorFmt = '';
        let logStepFmt;

        const fmtConfig = this.menuConfig.config;

        const indicatorStates = fmtConfig.indicatorStates || ['|', '/', '-', '\\'];
        const indicatorFinished = fmtConfig.indicatorFinished || '√';

        const indicator = {};
        const self = this;

        function updateIndicator(mci, isFinished) {
            indicator.mci = mci;

            if (isFinished) {
                indicator.text = indicatorFinished;
            } else {
                self.scanStatus.indicatorPos += 1;
                if (self.scanStatus.indicatorPos >= indicatorStates.length) {
                    self.scanStatus.indicatorPos = 0;
                }
                indicator.text = indicatorStates[self.scanStatus.indicatorPos];
            }
        }

        switch (stepInfo.step) {
            case 'start':
                logStepFmt = stepIndicatorFmt =
                    fmtConfig.scanningStartFormat || 'Scanning {fileName}';
                break;

            case 'hash_update':
                stepIndicatorFmt =
                    fmtConfig.calcHashFormat ||
                    'Calculating hash/checksums: {calcHashPercent}%';
                updateIndicator(MciViewIds.processing.calcHashIndicator);
                break;

            case 'hash_finish':
                stepIndicatorFmt =
                    fmtConfig.calcHashCompleteFormat ||
                    'Finished calculating hash/checksums';
                updateIndicator(MciViewIds.processing.calcHashIndicator, true);
                break;

            case 'archive_list_start':
                stepIndicatorFmt =
                    fmtConfig.extractArchiveListFormat || 'Extracting archive list';
                updateIndicator(MciViewIds.processing.archiveListIndicator);
                break;

            case 'archive_list_finish':
                fmtObj.archivedFileCount = stepInfo.archiveEntries.length;
                stepIndicatorFmt =
                    fmtConfig.extractArchiveListFinishFormat ||
                    'Archive list extracted ({archivedFileCount} files)';
                updateIndicator(MciViewIds.processing.archiveListIndicator, true);
                break;

            case 'archive_list_failed':
                stepIndicatorFmt =
                    fmtConfig.extractArchiveListFailedFormat ||
                    'Archive list extraction failed';
                break;

            case 'desc_files_start':
                stepIndicatorFmt =
                    fmtConfig.processingDescFilesFormat || 'Processing description files';
                updateIndicator(MciViewIds.processing.descFileIndicator);
                break;

            case 'desc_files_finish':
                stepIndicatorFmt =
                    fmtConfig.processingDescFilesFinishFormat ||
                    'Finished processing description files';
                updateIndicator(MciViewIds.processing.descFileIndicator, true);
                break;

            case 'finished':
                logStepFmt = stepIndicatorFmt =
                    fmtConfig.scanningStartFormat || 'Finished';
                break;
        }

        fmtObj.stepIndicatorText = stringFormat(stepIndicatorFmt, fmtObj);

        if (this.hasProcessingArt) {
            this.updateCustomViewTextsWithFilter(
                'processing',
                MciViewIds.processing.customRangeStart,
                fmtObj,
                { appendMultiLine: true }
            );

            if (indicator.mci && indicator.text) {
                this.setViewText('processing', indicator.mci, indicator.text);
            }

            if (logStepFmt) {
                this.setViewText(
                    'processing',
                    MciViewIds.processing.logStep,
                    stringFormat(logStepFmt, fmtObj),
                    { appendMultiLine: true }
                );
            }
        } else {
            this.client.term.pipeWrite(fmtObj.stepIndicatorText);
        }
    }

    scanFiles(cb) {
        const self = this;

        const results = {
            newEntries: [],
            dupes: [],
        };

        self.client.log.debug('Scanning upload(s)', { paths: this.recvFilePaths });

        let currentFileNum = 0;

        async.eachSeries(
            this.recvFilePaths,
            (filePath, nextFilePath) => {
                //  :TODO: virus scanning/etc. should occur around here

                currentFileNum += 1;

                self.scanStatus = {
                    indicatorPos: 0,
                };

                const scanOpts = {
                    areaTag: self.areaInfo.areaTag,
                    storageTag: self.areaInfo.storageTags[0],
                    hashTags: self.areaInfo.hashTags,
                };

                function handleScanStep(stepInfo, nextScanStep) {
                    stepInfo.totalFileNum = self.recvFilePaths.length;
                    stepInfo.currentFileNum = currentFileNum;

                    self.updateScanStepInfoViews(stepInfo);
                    return nextScanStep(null);
                }

                self.client.log.debug('Scanning file', { filePath: filePath });

                scanFile(
                    filePath,
                    scanOpts,
                    handleScanStep,
                    (err, fileEntry, dupeEntries) => {
                        if (err) {
                            return nextFilePath(err);
                        }

                        //  new or dupe?
                        if (dupeEntries.length > 0) {
                            //  1:n dupes found
                            self.client.log.debug('Duplicate file(s) found', {
                                dupeEntries: dupeEntries,
                            });

                            results.dupes = results.dupes.concat(dupeEntries);
                        } else {
                            //  new one
                            results.newEntries.push(fileEntry);
                        }

                        return nextFilePath(null);
                    }
                );
            },
            err => {
                return cb(err, results);
            }
        );
    }

    cleanupTempFiles() {
        temptmp.cleanup(paths => {
            Log.debug(
                { paths: paths, sessionId: temptmp.sessionId },
                'Temporary files cleaned up'
            );
        });
    }

    moveAndPersistUploadsToDatabase(newEntries) {
        const areaStorageDir = getAreaDefaultStorageDirectory(this.areaInfo);
        const self = this;

        async.eachSeries(
            newEntries,
            (newEntry, nextEntry) => {
                const src = paths.join(self.tempRecvDirectory, newEntry.fileName);
                const dst = paths.join(areaStorageDir, newEntry.fileName);

                moveFileWithCollisionHandling(src, dst, (err, finalPath) => {
                    if (err) {
                        self.client.log.error('Failed moving physical upload file', {
                            error: err.message,
                            fileName: newEntry.fileName,
                            source: src,
                            dest: dst,
                        });
                        return nextEntry(null); //  still try next file
                    } else if (dst !== finalPath) {
                        //  name changed; adjust before persist
                        newEntry.fileName = paths.basename(finalPath);
                    }

                    self.client.log.debug('Moved upload to area', { path: finalPath });

                    //  persist to DB
                    newEntry.persist(err => {
                        if (err) {
                            self.client.log.error(
                                'Failed persisting upload to database',
                                { path: finalPath, error: err.message }
                            );
                        }

                        return nextEntry(null); //  still try next file
                    });
                });
            },
            () => {
                //
                //  Finally, we can remove any temp files that we may have created
                //
                self.cleanupTempFiles();
            }
        );
    }

    prepDetailsForUpload(scanResults, cb) {
        async.eachSeries(
            scanResults.newEntries,
            (newEntry, nextEntry) => {
                newEntry.meta.upload_by_username = this.client.user.username;
                newEntry.meta.upload_by_user_id = this.client.user.userId;

                this.displayFileDetailsPageForUploadEntry(newEntry, (err, newValues) => {
                    if (err) {
                        return nextEntry(err);
                    }

                    if (!newEntry.descIsAnsi) {
                        newEntry.desc = _.trimEnd(newValues.shortDesc);
                    }

                    if (newValues.estYear.length > 0) {
                        newEntry.meta.est_release_year = newValues.estYear;
                    }

                    if (newValues.tags.length > 0) {
                        newEntry.setHashTags(newValues.tags);
                    }

                    return nextEntry(err);
                });
            },
            err => {
                delete this.fileDetailsCurrentEntrySubmitCallback;
                return cb(err, scanResults);
            }
        );
    }

    displayDupesPage(dupes, cb) {
        //
        //  If we have custom art to show, use it - else just dump basic info.
        //  Pause at the end in either case.
        //
        const self = this;

        async.waterfall(
            [
                function prepArtAndViewController(callback) {
                    self.prepViewControllerWithArt(
                        'dupes',
                        FormIds.dupes,
                        { clearScreen: true, trailingLF: false },
                        err => {
                            if (err) {
                                self.client.term.pipeWrite(
                                    '|00|07Duplicate upload(s) found:\n'
                                );
                                return callback(null, null);
                            }

                            const dupeListView = self.viewControllers.dupes.getView(
                                MciViewIds.dupes.dupeList
                            );
                            return callback(null, dupeListView);
                        }
                    );
                },
                function prepDupeObjects(dupeListView, callback) {
                    //  update dupe objects with additional info that can be used for formatString() and the like
                    async.each(
                        dupes,
                        (dupe, nextDupe) => {
                            FileEntry.loadBasicEntry(dupe.fileId, dupe, err => {
                                if (err) {
                                    return nextDupe(err);
                                }

                                const areaInfo = getFileAreaByTag(dupe.areaTag);
                                if (areaInfo) {
                                    dupe.areaName = areaInfo.name;
                                    dupe.areaDesc = areaInfo.desc;
                                }
                                return nextDupe(null);
                            });
                        },
                        err => {
                            return callback(err, dupeListView);
                        }
                    );
                },
                function populateDupeInfo(dupeListView, callback) {
                    const dupeInfoFormat =
                        self.menuConfig.config.dupeInfoFormat ||
                        '{fileName} @ {areaName}';

                    if (dupeListView) {
                        dupeListView.setItems(
                            dupes.map(dupe => stringFormat(dupeInfoFormat, dupe))
                        );
                        dupeListView.redraw();
                    } else {
                        dupes.forEach(dupe => {
                            self.client.term.pipeWrite(
                                `${stringFormat(dupeInfoFormat, dupe)}\n`
                            );
                        });
                    }

                    return callback(null);
                },
                function pause(callback) {
                    return self.pausePrompt(
                        { row: self.client.term.termHeight },
                        callback
                    );
                },
            ],
            err => {
                return cb(err);
            }
        );
    }

    processUploadedFiles() {
        //
        //  For each file uploaded, we need to process & gather information
        //
        const self = this;

        async.waterfall(
            [
                function prepNonBlind(callback) {
                    if (self.isBlindUpload()) {
                        return callback(null);
                    }

                    //
                    //  For non-blind uploads, batch is not supported, we expect a single file
                    //  in |recvFilePaths|. If not, it's an error (we don't want to process the wrong thing)
                    //
                    if (self.recvFilePaths.length > 1) {
                        self.client.log.warn(
                            { recvFilePaths: self.recvFilePaths },
                            'Non-blind upload received 2:n files'
                        );
                        return callback(
                            Errors.UnexpectedState(
                                `Non-blind upload expected single file but got received ${self.recvFilePaths.length}`
                            )
                        );
                    }

                    return callback(null);
                },
                function scan(callback) {
                    return self.scanFiles(callback);
                },
                function pause(scanResults, callback) {
                    if (self.hasProcessingArt) {
                        self.client.term.rawWrite(
                            ansiGoto(self.client.term.termHeight, 1)
                        );
                    } else {
                        self.client.term.write('\n');
                    }

                    self.pausePrompt(() => {
                        return callback(null, scanResults);
                    });
                },
                function displayDupes(scanResults, callback) {
                    if (0 === scanResults.dupes.length) {
                        return callback(null, scanResults);
                    }

                    return self.displayDupesPage(scanResults.dupes, () => {
                        return callback(null, scanResults);
                    });
                },
                function prepDetails(scanResults, callback) {
                    return self.prepDetailsForUpload(scanResults, callback);
                },
                function startMovingAndPersistingToDatabase(scanResults, callback) {
                    //
                    //  *Start* the process of moving files from their current |tempRecvDirectory|
                    //  locations -> their final area destinations. Don't make the user wait
                    //  here as I/O can take quite a bit of time. Log any failures.
                    //
                    self.moveAndPersistUploadsToDatabase(scanResults.newEntries);
                    return callback(null, scanResults.newEntries);
                },
                function sendEvent(uploadedEntries, callback) {
                    Events.emit(Events.getSystemEvents().UserUpload, {
                        user: self.client.user,
                        files: uploadedEntries,
                    });
                    return callback(null);
                },
            ],
            err => {
                if (err) {
                    self.client.log.warn('File upload error encountered', {
                        error: err.message,
                    });
                    self.cleanupTempFiles(); //  normally called after moveAndPersistUploadsToDatabase() is completed.
                }

                return self.prevMenu();
            }
        );
    }

    displayOptionsPage(cb) {
        const self = this;

        async.series(
            [
                function prepArtAndViewController(callback) {
                    return self.prepViewControllerWithArt(
                        'options',
                        FormIds.options,
                        { clearScreen: true, trailingLF: false },
                        callback
                    );
                },
                function populateViews(callback) {
                    const areaSelectView = self.viewControllers.options.getView(
                        MciViewIds.options.area
                    );
                    areaSelectView.setItems(
                        self.availAreas.map(areaInfo => areaInfo.name)
                    );

                    const uploadTypeView = self.viewControllers.options.getView(
                        MciViewIds.options.uploadType
                    );
                    const fileNameView = self.viewControllers.options.getView(
                        MciViewIds.options.fileName
                    );

                    const blindFileNameText =
                        self.menuConfig.config.blindFileNameText ||
                        '(blind - filename ignored)';

                    uploadTypeView.on('index update', idx => {
                        self.uploadType = 0 === idx ? 'blind' : 'non-blind';

                        if (self.isBlindUpload()) {
                            fileNameView.setText(blindFileNameText);
                            fileNameView.acceptsFocus = false;
                        } else {
                            fileNameView.clearText();
                            fileNameView.acceptsFocus = true;
                        }
                    });

                    //  sanatize filename for display when leaving the view
                    self.viewControllers.options.on('leave', prevView => {
                        if (prevView.id === MciViewIds.options.fileName) {
                            fileNameView.setText(
                                sanatizeFilename(fileNameView.getData())
                            );
                        }
                    });

                    self.uploadType = 'blind';
                    uploadTypeView.setFocusItemIndex(0); //  default to blind
                    fileNameView.setText(blindFileNameText);
                    areaSelectView.redraw();

                    return callback(null);
                },
            ],
            err => {
                if (cb) {
                    return cb(err);
                }
            }
        );
    }

    displayProcessingPage(cb) {
        return this.prepViewControllerWithArt(
            'processing',
            FormIds.processing,
            { clearScreen: true, trailingLF: false },
            err => {
                //  note: this art is not required
                this.hasProcessingArt = !err;

                return cb(null);
            }
        );
    }

    fileEntryHasDetectedDesc(fileEntry) {
        return fileEntry.desc && fileEntry.desc.length > 0;
    }

    displayFileDetailsPageForUploadEntry(fileEntry, cb) {
        const self = this;

        async.waterfall(
            [
                function prepArtAndViewController(callback) {
                    return self.prepViewControllerWithArt(
                        'fileDetails',
                        FormIds.fileDetails,
                        { clearScreen: true, trailingLF: false },
                        err => {
                            return callback(err);
                        }
                    );
                },
                function populateViews(callback) {
                    const descView = self.viewControllers.fileDetails.getView(
                        MciViewIds.fileDetails.desc
                    );
                    const tagsView = self.viewControllers.fileDetails.getView(
                        MciViewIds.fileDetails.tags
                    );
                    const yearView = self.viewControllers.fileDetails.getView(
                        MciViewIds.fileDetails.estYear
                    );

                    self.updateCustomViewTextsWithFilter(
                        'fileDetails',
                        MciViewIds.fileDetails.customRangeStart,
                        fileEntry
                    );

                    tagsView.setText(Array.from(fileEntry.hashTags).join(',')); //  :TODO: optional 'hashTagsSep' like file list/browse
                    yearView.setText(fileEntry.meta.est_release_year || '');

                    if (isAnsi(fileEntry.desc)) {
                        fileEntry.descIsAnsi = true;

                        return descView.setAnsi(
                            fileEntry.desc,
                            {
                                prepped: false,
                                forceLineTerm: true,
                            },
                            () => {
                                return callback(
                                    null,
                                    descView,
                                    'preview',
                                    MciViewIds.fileDetails.tags
                                );
                            }
                        );
                    } else {
                        const hasDesc = self.fileEntryHasDetectedDesc(fileEntry);
                        descView.setText(
                            hasDesc
                                ? fileEntry.desc
                                : getDescFromFileName(fileEntry.fileName),
                            { scrollMode: 'top' } //  override scroll mode; we want to be @ top
                        );
                        return callback(
                            null,
                            descView,
                            'edit',
                            hasDesc
                                ? MciViewIds.fileDetails.tags
                                : MciViewIds.fileDetails.desc
                        );
                    }
                },
                function finalizeViews(descView, descViewMode, focusId, callback) {
                    descView.setPropertyValue('mode', descViewMode);
                    descView.acceptsFocus = 'preview' === descViewMode ? false : true;
                    self.viewControllers.fileDetails.switchFocus(focusId);
                    return callback(null);
                },
            ],
            err => {
                //
                //  we only call |cb| here if there is an error
                //  else, wait for the current from to be submit - then call -
                //  this way we'll move on to the next file entry when ready
                //
                if (err) {
                    return cb(err);
                }

                self.fileDetailsCurrentEntrySubmitCallback = cb; //  stash for moduleMethods.fileDetailsContinue
            }
        );
    }
};
