import { Meteor } from 'meteor/meteor';
import { _ } from 'meteor/underscore';
import { Session } from 'meteor/session';

// OHIF Modules
import { OHIF } from 'meteor/ohif:core';
import 'meteor/ohif:viewerbase';

// Hanging Protocol local imports
import { HPMatcher } from './matcher/HPMatcher';
import { sortByScore } from './lib/sortByScore';
import './customViewportSettings';

/**
 * Import Constants
 */

const { OHIFError, metadata: { StudyMetadata, SeriesMetadata, InstanceMetadata, StudySummary } } = OHIF.viewerbase;

// Useful constants
const ABSTRACT_PRIOR_VALUE = 'abstractPriorValue';

// Define a global variable that will be used to refer to the Protocol Engine
// It must be populated by HP.setEngine when the Viewer is initialized and a ProtocolEngine
// is instantiated on top of the LayoutManager. If the global ProtocolEngine variable remains
// undefined, none of the HangingProtocol functions will operate.
ProtocolEngine = undefined;

/**
 * Sets the ProtocolEngine global given an instantiated ProtocolEngine. This is done so that
 * The functions in the package can depend on a ProtocolEngine variable, but this variable does
 * not have to be exported from the application level.
 *
 * (There may be a better way to do this, but for now this works with no real downside)
 *
 * @param protocolEngine An instantiated ProtocolEngine linked to a LayoutManager from the
 *                       Viewerbase package
 */
HP.setEngine = protocolEngine => {
    ProtocolEngine = protocolEngine;
};

/**
 * Gets the instantiated ProtocolEngine global object
 *
 * @returns protocolEngine An instantiated ProtocolEngine linked to a LayoutManager from the
 *                       Viewerbase package
 */
HP.getEngine = () => {
    return ProtocolEngine;
};

Meteor.startup(() => {
    HP.addCustomViewportSetting('wlPreset', 'Window/Level Preset', Object.create(null), (element, optionValue) => {
        if (_.findWhere(OHIF.viewer.wlPresets, { id: optionValue })) {
            OHIF.viewerbase.wlPresets.applyWLPreset(optionValue, element);
        }
    });
});

HP.ProtocolEngine = class ProtocolEngine {
    /**
     * Constructor
     * @param  {Object} layoutManager  Layout Manager Object
     * @param  {Array} studies        Array of study metadata
     * @param  {Map} priorStudies Map of prior studies
     * @param  {Object} studyMedadataSource Instance of StudyMetadataSource (ohif-viewerbase) Object to get study metadata
     */
    constructor(layoutManager, studies, priorStudies, studyMetadataSource) {

        const { LayoutManager, StudyMetadataSource } = OHIF.viewerbase;
        // -----------
        // Type Validations

        if (!(layoutManager instanceof LayoutManager)) {
            throw new OHIFError('ProtocolEngine::constructor layoutManager is not an instance of LayoutManager');
        }

        if (!(studyMetadataSource instanceof StudyMetadataSource)) {
            throw new OHIFError('ProtocolEngine::constructor studyMetadataSource is not an instance of StudyMetadataSource');
        }

        if (!(studies instanceof Array) && !studies.every(study => study instanceof StudyMetadata)) {
            throw new OHIFError('ProtocolEngine::constructor studies is not an array or it\'s items are not instances of StudyMetadata');
        }

        // --------------
        // Initialization

        this.LayoutManager = layoutManager;
        this.studies = studies;
        this.priorStudies = priorStudies instanceof Map ? priorStudies : new Map();
        this.studyMetadataSource = studyMetadataSource;

        // Put protocol engine in a known states
        this.reset();

        // Create an array for new stage ids to be stored
        // while editing a stage
        this.newStageIds = [];
    }

    /**
     * Resets the ProtocolEngine to the best match
     */
    reset() {
        const protocol = this.getBestProtocolMatch();

        this.setHangingProtocol(protocol);
    }

    /**
     * Retrieves the current Stage from the current Protocol and stage index
     *
     * @returns {*} The Stage model for the currently displayed Stage
     */
    getCurrentStageModel() {
        return this.protocol.stages[this.stage];
    }

    /**
     * Finds the best protocols from Protocol Store, matching each protocol matching rules
     * with the given study. The best protocol are orded by score and returned in an array
     * @param  {Object} study StudyMetadata instance object
     * @return {Array}       Array of match objects or an empty array if no match was found
     *                       Each match object has the score of the matching and the matched
     *                       protocol
     */
    findMatchByStudy(study) {
        OHIF.log.info('ProtocolEngine::findMatchByStudy');

        const matched = [];
        const studyInstance = study.getFirstInstance();

        // Set custom attribute for study metadata
        const numberOfAvailablePriors = this.getNumberOfAvailablePriors(study.getObjectID());

        HP.ProtocolStore.getProtocol().forEach(protocol => {
            // Clone the protocol's protocolMatchingRules array
            // We clone it so that we don't accidentally add the
            // numberOfPriorsReferenced rule to the Protocol itself.
            let rules = protocol.protocolMatchingRules.slice();
            if (!rules) {
                return;
            }

            // Check if the study has the minimun number of priors used by the protocol.
            const numberOfPriorsReferenced = protocol.getNumberOfPriorsReferenced();
            if (numberOfPriorsReferenced > numberOfAvailablePriors) {
                return;
            }

            // Run the matcher and get matching details
            const matchedDetails = HPMatcher.match(studyInstance, rules);
            const score = matchedDetails.score;

            // The protocol matched some rule, add it to the matched list
            if (score > 0) {
                matched.push({
                    score,
                    protocol
                });
            }
        });

        // If no matches were found, select the default protocol
        if (!matched.length) {
            const defaultProtocol = HP.ProtocolStore.getProtocol('defaultProtocol');

            return [{
                score: 1,
                protocol: defaultProtocol
            }];
        }

        // Sort the matched list by score
        sortByScore(matched);

        OHIF.log.info('ProtocolEngine::findMatchByStudy matched', matched);

        return matched;
    }

    /**
     * Populates the MatchedProtocols Collection by running the matching procedure
     */
    updateProtocolMatches() {
        OHIF.log.info('ProtocolEngine::updateProtocolMatches');

        // Clear all data from the MatchedProtocols Collection
        MatchedProtocols.remove({});

        // For each study, find the matching protocols
        this.studies.forEach(study => {
            const matched = this.findMatchByStudy(study);

            // For each matched protocol, check if it is already in MatchedProtocols
            matched.forEach(matchedDetail => {
                const protocol = matchedDetail.protocol;
                if (!protocol) {
                    return;
                }

                const protocolInCollection = MatchedProtocols.findOne({
                    id: protocol.id
                });

                // If it is not already in the MatchedProtocols Collection, insert it with its score
                if (!protocolInCollection) {
                    OHIF.log.info('ProtocolEngine::updateProtocolMatches inserting protocol match', matchedDetail);
                    MatchedProtocols.insert(matchedDetail);
                }
            });
        });
    }

    /**
     * Return the best matched Protocol to the current study or set of studies
     * @returns {*}
     */
    getBestProtocolMatch() {
        // Run the matching to populate the MatchedProtocols Collection
        this.updateProtocolMatches();

        // Retrieve the highest scoring Protocol
        const sorted = MatchedProtocols.find({}, {
            sort: {
                score: -1
            },
            limit: 1
        }).fetch();

        // Highest scoring Protocol
        const bestMatch = sorted[0].protocol;

        OHIF.log.info('ProtocolEngine::getBestProtocolMatch bestMatch', bestMatch);

        return bestMatch;
    }

    /**
     * Get the number of prior studies supplied in the priorStudies map property.
     *
     * @param {String} studyObjectID The study object ID of the study whose priors are needed
     * @returns {number} The number of available prior studies with the same PatientID
     */
    getNumberOfAvailablePriors(studyObjectID) {
        const priors = this.getAvailableStudyPriors(studyObjectID);

        return priors.length;
    }

    /**
     * Get the array of prior studies from a specific study.
     *
     * @param {String} studyObjectID The study object ID of the study whose priors are needed
     * @returns {Array} The array of available priors or an empty array
     */
    getAvailableStudyPriors(studyObjectID) {
        const priors = this.priorStudies.get(studyObjectID);

        return priors instanceof Array ? priors : [];
    }

    // Match images given a list of Studies and a Viewport's image matching reqs
    matchImages(viewport, viewportIndex) {
        OHIF.log.info('ProtocolEngine::matchImages');

        const { studyMatchingRules, seriesMatchingRules, imageMatchingRules: instanceMatchingRules } = viewport;

        const matchingScores = [];
        const currentStudy = this.studies[0]; // @TODO: Should this be: this.studies[this.currentStudy] ???
        const firstInstance = currentStudy.getFirstInstance();

        let highestStudyMatchingScore = 0;
        let highestSeriesMatchingScore = 0;
        let highestImageMatchingScore = 0;
        let bestMatch;

        // Set custom attribute for study metadata and it's first instance
        currentStudy.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
        if (firstInstance instanceof InstanceMetadata) {
            firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
        }

        // Only used if study matching rules has abstract prior values defined...
        let priorStudies;

        studyMatchingRules.forEach(rule => {
            if (rule.attribute === ABSTRACT_PRIOR_VALUE) {
                const validatorType = Object.keys(rule.constraint)[0];
                const validator = Object.keys(rule.constraint[validatorType])[0];

                let abstractPriorValue = rule.constraint[validatorType][validator];
                abstractPriorValue = parseInt(abstractPriorValue, 10);
                // TODO: Restrict or clarify validators for abstractPriorValue?

                // No need to call it more than once...
                if (!priorStudies) {
                    priorStudies = this.getAvailableStudyPriors(currentStudy.getObjectID());
                }

                // TODO: Revisit this later: What about two studies with the same
                // study date?

                let priorStudy;
                if (abstractPriorValue === -1) {
                    priorStudy = priorStudies[priorStudies.length - 1];
                } else {
                    const studyIndex = Math.max(abstractPriorValue - 1, 0);
                    priorStudy = priorStudies[studyIndex];
                }

                // Invalid data
                if (!(priorStudy instanceof StudyMetadata) && !(priorStudy instanceof StudySummary)) {
                    return;
                }

                const priorStudyObjectID = priorStudy.getObjectID();

                // Check if study metadata is already in studies list
                if (this.studies.find(study => study.getObjectID() === priorStudyObjectID)) {
                    return;
                }

                // Get study metadata if necessary and load study in the viewer (each viewer should provide it's own load study method)
                this.studyMetadataSource.loadStudy(priorStudy).then(studyMetadata => {
                    // Set the custom attribute abstractPriorValue for the study metadata
                    studyMetadata.setCustomAttribute(ABSTRACT_PRIOR_VALUE, abstractPriorValue);

                    // Also add custom attribute
                    const firstInstance = studyMetadata.getFirstInstance();
                    if (firstInstance instanceof InstanceMetadata) {
                        firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, abstractPriorValue);
                    }

                    // Insert the new study metadata
                    this.studies.push(studyMetadata);

                    // Update the viewport to refresh layout manager with new study
                    this.updateViewports(viewportIndex);
                }, error => {
                    OHIF.log.warn(error);
                    throw new OHIFError(`ProtocolEngine::matchImages could not get study metadata for the Study with the following ObjectID: ${priorStudyObjectID}`);
                });
            }
            // TODO: Add relative Date / time
        });

        this.studies.forEach(study => {
            const studyMatchDetails = HPMatcher.match(study.getFirstInstance(), studyMatchingRules);
            if ((studyMatchingRules.length && !studyMatchDetails.score) ||
                studyMatchDetails.score < highestStudyMatchingScore) {
                return;
            }

            highestStudyMatchingScore = studyMatchDetails.score;

            study.forEachSeries(series => {
                const seriesMatchDetails = HPMatcher.match(series.getFirstInstance(), seriesMatchingRules);
                if ((seriesMatchingRules.length && !seriesMatchDetails.score) ||
                    seriesMatchDetails.score < highestSeriesMatchingScore) {
                    return;
                }

                highestSeriesMatchingScore = seriesMatchDetails.score;

                series.forEachInstance((instance, index) => {
                    // This tests to make sure there is actually image data in this instance
                    // TODO: Change this when we add PDF and MPEG support
                    // See https://ohiforg.atlassian.net/browse/LT-227
                    // sopClassUid = x00080016
                    // rows = x00280010
                    if (!OHIF.viewerbase.isImage(instance.getTagValue('x00080016')) && !instance.getTagValue('x00280010')) {
                        return;
                    }

                    const instanceMatchDetails = HPMatcher.match(instance, instanceMatchingRules);

                    const matchDetails = {
                        passed: [],
                        failed: []
                    };

                    matchDetails.passed = matchDetails.passed.concat(instanceMatchDetails.details.passed);
                    matchDetails.passed = matchDetails.passed.concat(seriesMatchDetails.details.passed);
                    matchDetails.passed = matchDetails.passed.concat(studyMatchDetails.details.passed);

                    matchDetails.failed = matchDetails.failed.concat(instanceMatchDetails.details.failed);
                    matchDetails.failed = matchDetails.failed.concat(seriesMatchDetails.details.failed);
                    matchDetails.failed = matchDetails.failed.concat(studyMatchDetails.details.failed);

                    const totalMatchScore = instanceMatchDetails.score + seriesMatchDetails.score + studyMatchDetails.score;
                    const currentSOPInstanceUID = instance.getSOPInstanceUID();

                    const imageDetails = {
                        studyInstanceUid: study.getStudyInstanceUID(),
                        seriesInstanceUid: series.getSeriesInstanceUID(),
                        sopInstanceUid: currentSOPInstanceUID,
                        currentImageIdIndex: index,
                        matchingScore: totalMatchScore,
                        matchDetails: matchDetails,
                        sortingInfo: {
                            score: totalMatchScore,
                            study: instance.getTagValue('x00080020') + instance.getTagValue('x00080030'), // StudyDate = x00080020 StudyTime = x00080030
                            series: parseInt(instance.getTagValue('x00200011')), // TODO: change for seriesDateTime SeriesNumber = x00200011
                            instance: parseInt(instance.getTagValue('x00200013')) // TODO: change for acquisitionTime InstanceNumber = x00200013
                        }
                    };

                    // Find the displaySet
                    const displaySet = study.findDisplaySet(displaySet => displaySet.images.find(image => image.getSOPInstanceUID() === currentSOPInstanceUID));

                    // If the instance was found, set the displaySet ID
                    if (displaySet) {
                        imageDetails.displaySetInstanceUid = displaySet.getUID();
                        imageDetails.imageId = instance.getImageId();
                    }

                    if ((totalMatchScore > highestImageMatchingScore) || !bestMatch) {
                        highestImageMatchingScore = totalMatchScore;
                        bestMatch = imageDetails;
                    }

                    matchingScores.push(imageDetails);
                });
            });
        });

        // Sort the matchingScores
        const sortingFunction = OHIF.utils.sortBy({
            name: 'score',
            reverse: true
        }, {
            name: 'study',
            reverse: true
        }, {
            name: 'instance'
        }, {
            name: 'series'
        });
        matchingScores.sort((a, b) => sortingFunction(a.sortingInfo, b.sortingInfo));

        OHIF.log.info('ProtocolEngine::matchImages bestMatch', bestMatch);

        return {
            bestMatch,
            matchingScores
        };
    }

    /**
     * Rerenders viewports that are part of the current ProtocolEngine's LayoutManager
     * using the matching rules internal to each viewport.
     *
     * If this function is provided the index of a viewport, only the specified viewport
     * is rerendered.
     *
     * @param viewportIndex
     */
    updateViewports(viewportIndex) {
        OHIF.log.info(`ProtocolEngine::updateViewports viewportIndex: ${viewportIndex}`);

        // Make sure we have an active protocol with a non-empty array of display sets
        if (!this.getNumProtocolStages()) {
            return;
        }

        // Retrieve the current display set in the display set sequence
        const stageModel = this.getCurrentStageModel();

        // If the current display set does not fulfill the requirements to be displayed,
        // stop here.
        if (!stageModel ||
            !stageModel.viewportStructure ||
            !stageModel.viewports ||
            !stageModel.viewports.length) {
            return;
        }

        // Retrieve the layoutTemplate associated with the current display set's viewport structure
        // If no such template name exists, stop here.
        const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
        if (!layoutTemplateName) {
            return;
        }

        // Retrieve the properties associated with the current display set's viewport structure template
        // If no such layout properties exist, stop here.
        const layoutProps = stageModel.viewportStructure.properties;
        if (!layoutProps) {
            return;
        }

        // Create an empty array to store the output viewportData
        const viewportData = [];

        // Empty the matchDetails associated with the ProtocolEngine.
        // This will be used to store the pass/fail details and score
        // for each of the viewport matching procedures
        this.matchDetails = [];

        // Loop through each viewport
        stageModel.viewports.forEach((viewport, viewportIndex) => {
            const details = this.matchImages(viewport, viewportIndex);

            this.matchDetails[viewportIndex] = details;

            // Convert any YES/NO values into true/false for Cornerstone
            const cornerstoneViewportParams = {};

            // Cache viewportSettings keys
            const viewportSettingsKeys = Object.keys(viewport.viewportSettings);

            viewportSettingsKeys.forEach(key => {
                let value = viewport.viewportSettings[key];
                if (value === 'YES') {
                    value = true;
                } else if (value === 'NO') {
                    value = false;
                }

                cornerstoneViewportParams[key] = value;
            });

            // imageViewerViewports occasionally needs relevant layout data in order to set
            // the element style of the viewport in question
            const currentViewportData = {
                viewportIndex,
                viewport: cornerstoneViewportParams,
                ...layoutProps
            };

            const customSettings = [];
            viewportSettingsKeys.forEach(id => {
                const setting = HP.CustomViewportSettings[id];
                if (!setting) {
                    return;
                }

                customSettings.push({
                    id: id,
                    value: viewport.viewportSettings[id]
                });
            });

            currentViewportData.renderedCallback = element => {
                //console.log('renderedCallback for ' + element.id);
                customSettings.forEach(customSetting => {
                    OHIF.log.info(`ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}`);
                    OHIF.log.info(`ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}`);

                    const setting = HP.CustomViewportSettings[customSetting.id];
                    setting.callback(element, customSetting.value);
                });
            };

            let currentMatch = details.bestMatch;
            let currentPosition = 1;
            const scoresLength = details.matchingScores.length;
            while (currentPosition < scoresLength && _.findWhere(viewportData, {
                imageId: currentMatch.imageId
            })) {
                currentMatch = details.matchingScores[currentPosition];
                currentPosition++;
            }

            if (currentMatch && currentMatch.imageId) {
                currentViewportData.studyInstanceUid = currentMatch.studyInstanceUid;
                currentViewportData.seriesInstanceUid = currentMatch.seriesInstanceUid;
                currentViewportData.sopInstanceUid = currentMatch.sopInstanceUid;
                currentViewportData.currentImageIdIndex = currentMatch.currentImageIdIndex;
                currentViewportData.displaySetInstanceUid = currentMatch.displaySetInstanceUid;
                currentViewportData.imageId = currentMatch.imageId;
            }

            // @TODO Why should we throw an exception when a best match is not found? This was aborting the whole process.
            // if (!currentViewportData.displaySetInstanceUid) {
            //     throw new OHIFError('ProtocolEngine::updateViewports No matching display set found?');
            // }

            viewportData.push(currentViewportData);
        });

        this.LayoutManager.layoutTemplateName = layoutTemplateName;
        this.LayoutManager.layoutProps = layoutProps;
        this.LayoutManager.viewportData = viewportData;

        if (viewportIndex !== undefined && viewportData[viewportIndex]) {
            this.LayoutManager.rerenderViewportWithNewDisplaySet(viewportIndex, viewportData[viewportIndex]);
        } else {
            this.LayoutManager.updateViewports();
        }
    }

    /**
     * Sets the current Hanging Protocol to the specified Protocol
     * An optional argument can also be used to prevent the updating of the Viewports
     *
     * @param newProtocol
     * @param updateViewports
     */
    setHangingProtocol(newProtocol, updateViewports = true) {
        OHIF.log.info('ProtocolEngine::setHangingProtocol newProtocol', newProtocol);
        OHIF.log.info(`ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}`);

        // Reset the array of newStageIds
        this.newStageIds = [];

        if (HP.Protocol.prototype.isPrototypeOf(newProtocol)) {
            this.protocol = newProtocol;
        } else {
            this.protocol = new HP.Protocol();
            this.protocol.fromObject(newProtocol);
        }

        this.stage = 0;

        // Update viewports by default
        if (updateViewports) {
            this.updateViewports();
        }

        MatchedProtocols.update({}, {
            $set: {
                selected: false
            }
        }, {
            multi: true
        });

        MatchedProtocols.update({
            id: this.protocol.id
        }, {
            $set: {
                selected: true
            }
        });

        Session.set('HangingProtocolName', this.protocol.name);
        Session.set('HangingProtocolStage', this.stage);
    }

     /**
     * Check if the next stage is available
     * @return {Boolean} True if next stage is available or false otherwise
     */
    isNextStageAvailable() {
        const numberOfStages = this.getNumProtocolStages();

        return this.stage + 1 < numberOfStages;
    }

     /**
     * Check if the previous stage is available
     * @return {Boolean} True if previous stage is available or false otherwise
     */
    isPreviousStageAvailable() {
        return this.stage - 1 >= 0;
    }

    /**
     * Changes the current stage to a new stage index in the display set sequence.
     * It checks if the next stage exists.
     *
     * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage
     * @return {Boolean} True if new stage has set or false, otherwise
     */
    setCurrentProtocolStage(stageAction) {
        // Check if previous or next stage is available
        if (stageAction === -1 && !this.isPreviousStageAvailable()) {
            return false;
        } else if (stageAction === 1 && !this.isNextStageAvailable()) {
            return false;
        }

        // Sets the new stage
        this.stage += stageAction;

        // Log the new stage
        OHIF.log.info(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`);

        // Set stage Session variable for reactivity
        Session.set('HangingProtocolStage', this.stage);

        // Since stage has changed, we need to update the viewports
        // and redo matchings
        this.updateViewports();

        // Everything went well
        return true;
    }

    /**
     * Retrieves the number of Stages in the current Protocol or
     * undefined if no protocol or stages are set
     */
    getNumProtocolStages() {
        if (!this.protocol || !this.protocol.stages || !this.protocol.stages.length) {
            return;
        }

        return this.protocol.stages.length;
    }

    /**
     * Switches to the next protocol stage in the display set sequence
     */
    nextProtocolStage() {
        OHIF.log.info('ProtocolEngine::nextProtocolStage');

        if (!this.setCurrentProtocolStage(1)) {
            // Just for logging purpose
            OHIF.log.info('ProtocolEngine::nextProtocolStage failed');
        }
    }

    /**
     * Switches to the previous protocol stage in the display set sequence
     */
    previousProtocolStage() {
        OHIF.log.info('ProtocolEngine::previousProtocolStage');

        if (!this.setCurrentProtocolStage(-1)) {
            // Just for logging purpose
            OHIF.log.info('ProtocolEngine::previousProtocolStage failed');
        }
    }
};
