import OHIFError from '../classes/OHIFError.js';
import metadata from '../classes/metadata/';
import { StudyMetadataSource } from '../classes/StudyMetadataSource.js';
import { isImage } from '../utils/isImage.js';
import { HPMatcher } from './HPMatcher.js';
import { sortByScore } from './lib/sortByScore';
import log from '../log.js';
import sortBy from '../utils/sortBy.js';
import { CustomViewportSettings } from './customViewportSettings';
import Protocol from './classes/Protocol';
import { ProtocolStore } from './protocolStore/classes';

/**
 * Import Constants
 */
const { StudyMetadata, InstanceMetadata, StudySummary } = metadata;

// Useful constants
const ABSTRACT_PRIOR_VALUE = 'abstractPriorValue';

export default class ProtocolEngine {
  matchedProtocols = new Map();
  matchedProtocolScores = {};

  /**
   * Constructor
   * @param  {ProtocolStore} protocolStore Protocol Store used to keep track of all hanging protocols
   * @param  {Array} studies Array of study metadata
   * @param  {Map} priorStudies Map of prior studies
   * @param  {Object} studyMetadataSource Instance of StudyMetadataSource (ohif-viewerbase) Object to get study metadata
   * @param  {Object} options
   */
  constructor(
    protocolStore,
    studies,
    priorStudies,
    studyMetadataSource,
    options = {}
  ) {
    // -----------
    // Type Validations
    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.protocolStore = protocolStore;
    this.studies = studies;
    this.priorStudies = priorStudies instanceof Map ? priorStudies : new Map();
    this.studyMetadataSource = studyMetadataSource;
    this.options = options;

    // Put protocol engine in a known state
    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) {
    log.trace('ProtocolEngine::findMatchByStudy');

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

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

    this.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 = this.protocolStore.getProtocol('defaultProtocol');

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

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

    log.trace('ProtocolEngine::findMatchByStudy matched', matched);

    return matched;
  }

  _clearMatchedProtocols() {
    this.matchedProtocols.clear();
    this.matchedProtocolScores = {};
  }
  /**
   * Populates the MatchedProtocols Collection by running the matching procedure
   */
  updateProtocolMatches() {
    log.trace('ProtocolEngine::updateProtocolMatches');

    // Clear all data currently in matchedProtocols
    this._clearMatchedProtocols();

    // 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;
        }

        // If it is not already in the MatchedProtocols Collection, insert it with its score
        if (!this.matchedProtocols.has(protocol.id)) {
          log.trace(
            'ProtocolEngine::updateProtocolMatches inserting protocol match',
            matchedDetail
          );
          this.matchedProtocols.set(protocol.id, protocol);
          this.matchedProtocolScores[protocol.id] = matchedDetail.score;
        }
      });
    });
  }

  _largestKeyByValue(obj) {
    return Object.keys(obj).reduce((a, b) => (obj[a] > obj[b] ? a : b));
  }

  _getHighestScoringProtocol() {
    if (!Object.keys(this.matchedProtocolScores).length) {
      return this.protocolStore.getProtocol('defaultProtocol');
    }
    const highestScoringProtocolId = this._largestKeyByValue(
      this.matchedProtocolScores
    );
    return this.matchedProtocols.get(highestScoringProtocolId);
  }

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

    // Retrieve the highest scoring Protocol
    const bestMatch = this._getHighestScoringProtocol();

    log.trace('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) {
    return this.getAvailableStudyPriors(studyObjectID).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) {
    log.trace('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;

    // 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 => {
            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
      );

      // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
      if (
        studyMatchDetails.requiredFailed === true ||
        studyMatchDetails.score < highestStudyMatchingScore
      ) {
        return;
      }

      highestStudyMatchingScore = studyMatchDetails.score;

      study.forEachSeries(series => {
        const seriesMatchDetails = HPMatcher.match(
          series.getFirstInstance(),
          seriesMatchingRules
        );

        // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
        if (
          seriesMatchDetails.requiredFailed === true ||
          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 (
            !isImage(instance.getTagValue('x00080016')) &&
            !instance.getTagValue('x00280010')
          ) {
            return;
          }

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

          // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
          if (instanceMatchDetails.requiredFailed === true) {
            return;
          }

          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();
          }

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

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

    const bestMatch = matchingScores[0];

    log.trace('ProtocolEngine::matchImages bestMatch', bestMatch);

    return {
      bestMatch,
      matchingScores,
    };
  }

  /**
   * Sets the current layout
   *
   * @param {number} numRows
   * @param {number} numColumns
   */
  setLayout(numRows, numColumns) {
    if (numRows < 1 && numColumns < 1) {
      log.error(`Invalid layout ${numRows} x ${numColumns}`);
      return;
    }

    if (typeof this.options.setLayout !== 'function') {
      log.error('Hanging Protocol Engine setLayout callback is not defined');
      return;
    }

    let viewports = [];
    const numViewports = numRows * numColumns;

    for (let i = 0; i < numViewports; i++) {
      viewports.push({});
    }

    this.options.setLayout({ numRows, numColumns, viewports });
  }

  /**
   * Rerenders viewports that are part of the current layout manager
   * 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) {
    log.trace(
      `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 stage
    const stageModel = this.getCurrentStageModel();

    // If the current stage 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 = 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 => {
          log.trace(
            `ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}`
          );
          log.trace(
            `ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}`
          );

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

      let currentMatch = details.bestMatch;
      let currentPosition = 1;
      const scoresLength = details.matchingScores.length;
      while (
        currentPosition < scoresLength &&
        viewportData.find(a => a.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.setLayout(layoutProps.rows, layoutProps.columns);

    if (typeof this.options.setViewportSpecificData !== 'function') {
      log.error(
        'Hanging Protocol Engine setViewportSpecificData callback is not defined'
      );
      return;
    }

    // If viewportIndex is defined, then update only that viewport
    if (viewportIndex !== undefined && viewportData[viewportIndex]) {
      this.options.setViewportSpecificData(
        viewportIndex,
        viewportData[viewportIndex]
      );
      return;
    }

    // Update all viewports
    viewportData.forEach(viewportSpecificData => {
      this.options.setViewportSpecificData(
        viewportSpecificData.viewportIndex,
        viewportSpecificData
      );
    });
  }

  /**
   * 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) {
    log.trace('ProtocolEngine::setHangingProtocol newProtocol', newProtocol);
    log.trace(
      `ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}`
    );

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

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

    this.stage = 0;

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

  /**
   * 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
    log.trace(`ProtocolEngine::setCurrentProtocolStage stage = ${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() {
    log.trace('ProtocolEngine::nextProtocolStage');

    if (!this.setCurrentProtocolStage(1)) {
      log.trace('ProtocolEngine::nextProtocolStage failed');
    }
  }

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

    if (!this.setCurrentProtocolStage(-1)) {
      log.trace('ProtocolEngine::previousProtocolStage failed');
    }
  }
}
