/**
 * Silex, live web creation
 * http://projects.silexlabs.org/?/silex/
 *
 * Copyright (c) 2012 Silex Labs
 * http://www.silexlabs.org/
 *
 * Silex is available under the GPL license
 * http://www.silexlabs.org/silex/silex-licensing/
 */

/**
 * @fileoverview
 *   This class is used to access Silex elements properties
 */

import { Constants } from '../../Constants';
import { Font, Model, View } from '../types';
import { Style } from '../utils/style';
import { ComponentData, CssRule, JsonData, ProdotypeData, ProdotypeTypes, SilexData, SilexId, StyleData, StyleName } from './Data';

export interface CSSRuleInfo {
  rule: CSSRule;
  parent: CSSRule|StyleSheet;
  index: number;
}

/**
 * @param model  model class which holds the other models
 * @param view  view class which holds the other views
 */
export class Property {
  static EMPTY_PRODOTYPE_DATA: ProdotypeData = {component: {}, style: {}};

  /**
   * constant for the ID of the style tag
   * containing all CSS rules for the elements on stage
   * which are being edited with the wysiwyg
   */
  static INLINE_STYLE_TAG_CLASS_NAME = 'silex-inline-styles';

  /**
   * constant for the ID of the HTML node used
   * to store Silex data as a JSON object of type SilexData
   * containing all CSS rules for the elements on stage
   * which are being edited with the wysiwyg
   */
  static JSON_STYLE_TAG_CLASS_NAME = 'silex-json-styles';

  /**
   * constant for the prefix of the IDs given to Silex editable elements
   */
  static ELEMENT_ID_PREFIX = 'silex-id-';

  /**
   * constant for the value of media query for mobile version
   * @static
   */
  static MOBILE_MEDIA_QUERY = 'only screen and (max-width: 480px), only screen and (max-device-width: 480px)';

  /**
   * the current file's silex style sheet which holds silex elements styles
   * this is stored for performance reasons
   */
  styleSheet: CSSStyleSheet = null;

  /**
   * a number appended to the current timestamp in order to make unique Silex
   * IDs
   */
  nextId: number = 0;
  stylesObj: SilexData = {};
  fonts: Font[] = [];
  mobileStylesObj: SilexData = {};

  /**
   * arbitrary data for prodotype components
   */
  prodotypeDataObj: ProdotypeData = Property.EMPTY_PRODOTYPE_DATA;

  constructor(public model: Model, public view: View) {}

  setFonts(fonts: Font[]) {
    this.fonts = fonts;
  }

  /**
   * @return returns a copy of this.fonts
   */
  getFonts(): Font[] {
    return this.fonts.slice();
  }

  /**
   * get/set Silex ID
   * @return uniqueId
   */
  getSilexId(element: HTMLElement): SilexId {
    return element.getAttribute(Constants.ELEMENT_ID_ATTR_NAME);
  }

  /**
   * get/set Silex ID
   */
  setSilexId(element: HTMLElement, uniqueId: SilexId) {
    const oldId = this.getSilexId(element);
    if (oldId) {
      element.classList.remove(oldId);
    }
    element.setAttribute(Constants.ELEMENT_ID_ATTR_NAME, uniqueId);
    element.classList.add(uniqueId);
  }

  /**
   * @param opt_doc docment of the iframe containing the website
   */
  getElementBySilexId(uniqueId: SilexId, opt_doc?: Document):
      Element {
    opt_doc = opt_doc || this.model.file.getContentDocument();
    return opt_doc.querySelector(
        '[' + Constants.ELEMENT_ID_ATTR_NAME + '="' + uniqueId + '"]');
  }

  /**
   * @param opt_doc docment of the iframe containing the website
   */
  generateSilexId(opt_doc?: Document): SilexId {
    let uniqueId;
    do {
      uniqueId = Date.now().toString() + '-' + this.nextId++;
    } while (this.getElementBySilexId(uniqueId, opt_doc));
    return uniqueId;
  }

  /**
   * @param doc docment of the iframe containing the website
   */
  initSilexId(element: HTMLElement, doc?: Document) {
    // add the selector for this element
    const idAndClass = Property.ELEMENT_ID_PREFIX + this.generateSilexId(doc);
    this.setSilexId(element, idAndClass);
  }

  /**
   * Convert the styles to json and save it in a script tag
   */
  saveProperties(doc) {
    let styleTag = doc.querySelector('.' + Property.JSON_STYLE_TAG_CLASS_NAME);
    if (!styleTag) {
      styleTag = doc.createElement('script');
      styleTag.type = 'text/json';
      styleTag.classList.add(Property.JSON_STYLE_TAG_CLASS_NAME);
      doc.head.appendChild(styleTag);
    }

    // always save as json, it used to be javascript and sometimes it tabs mess
    // up the json
    styleTag.type = 'text/json';
    const obj = ({
      fonts: this.fonts || [],
      desktop: this.stylesObj || {},
      mobile: this.mobileStylesObj || {},
      prodotypeData: {
        component: this.prodotypeDataObj.component || {},
        style: this.prodotypeDataObj.style || {},
      },
    } as JsonData);

    // TODO: it is useless to store an array, a single object would be better
    styleTag.innerHTML = JSON.stringify([obj]);
  }

  /**
   * Load the styles from the json saved in a script tag
   */
  loadProperties(doc) {
    const styleTag = doc.querySelector('.' + Property.JSON_STYLE_TAG_CLASS_NAME);
    if (styleTag != null ) {
      const styles = (JSON.parse(styleTag.innerHTML)[0] as any);
      this.fonts = styles.fonts || [];
      this.stylesObj = styles.desktop || {};
      this.mobileStylesObj = styles.mobile || {};
      this.prodotypeDataObj = styles.prodotypeData &&
              styles.prodotypeData.component &&
              styles.prodotypeData.style ?
          ({
            component: styles.prodotypeData.component,
            style: styles.prodotypeData.style,
          } as ProdotypeData) :
          Property.EMPTY_PRODOTYPE_DATA;

      // FIXME: put this in backward compat
      if (styles.componentData) {
        this.prodotypeDataObj.component = styles.componentData;
      }
    } else {
      this.fonts = [];
      this.stylesObj = {};
      this.mobileStylesObj = {};
      this.prodotypeDataObj = Property.EMPTY_PRODOTYPE_DATA;
      console.info('Warning: no JSON styles array found in the dom');
    }
  }

  /**
   * check existance and possibly create a style tag holding Silex elements
   * styles
   * @param doc docment of the iframe containing the website
   */
  initStyles(doc: Document): HTMLElement {
    // make sure of the existance of the style tag with Silex definitions
    let styleTag: HTMLElement = doc.querySelector('.' + Property.INLINE_STYLE_TAG_CLASS_NAME);
    if (!styleTag) {
      styleTag = doc.createElement('style');
      styleTag.classList.add(Property.INLINE_STYLE_TAG_CLASS_NAME);
      styleTag.setAttribute('type', 'text/css');
      doc.head.appendChild(styleTag);
    }
    this.styleSheet = null;
    for (const s of doc.styleSheets) {
      if (s.ownerNode &&
          s.ownerNode === styleTag) {
        this.styleSheet = s as CSSStyleSheet;
      }
    }
    if (this.styleSheet === null) {
      console.error('no stylesheet found');
    }
    return styleTag;
  }

  /**
   * get / set the data associated with an ID
   * if opt_prodotypeData is null this data set will removed
   */
  setComponentData(id: SilexId, opt_prodotypeData?: ComponentData) {
    this.setProdotypeData(id, ProdotypeTypes.COMPONENT, opt_prodotypeData);
  }

  /**
   * get / set the data associated with an ID
   * if opt_prodotypeData is null this data set will removed
   */
  setStyleData(id: SilexId, opt_prodotypeData?: StyleData) {
    this.setProdotypeData(id, ProdotypeTypes.STYLE, opt_prodotypeData);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  getComponentData(id: SilexId): ComponentData {
    return (this.getProdotypeData(id, ProdotypeTypes.COMPONENT) as ComponentData |null);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  getStyleData(id: StyleName): StyleData {
    return (this.getProdotypeData(id, ProdotypeTypes.STYLE) as StyleData |null);
  }

  /**
   * get / set the data associated with an element
   * if opt_componentData is null this will remove the rule
   */
  setElementComponentData(element: HTMLElement, opt_componentData?: ComponentData) {
    // call private generic method
    this.setElementData(  element, ProdotypeTypes.COMPONENT, opt_componentData);
  }

  /**
   * get / set the data associated with an element
   * if opt_componentData is null this will remove the rule
   */
  setElementStyleData(element: HTMLElement, opt_componentData?: StyleData) {
    // call private generic method
    this.setElementData(
        element, ProdotypeTypes.STYLE, opt_componentData);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  getElementComponentData(element: HTMLElement): ComponentData {
    // call private generic method
    return (
        this.getElementData(
            element, ProdotypeTypes.COMPONENT) as
            ComponentData |
        null);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  getElementStyleData(element: HTMLElement): StyleData {
    // call private generic method
    return (
        this.getElementData(element, ProdotypeTypes.STYLE) as
            StyleData |
        null);
  }

  /**
   * get / set the css style of an element
   * this creates or update a rule in the style tag with id
   * INLINE_STYLE_TAG_CLASS_NAME if style is null this will remove the rule
   */
  setStyle(element: HTMLElement, styleObj: any, isMobile = this.view.workspace.getMobileEditor()) {
    const deleteStyle = !styleObj;
    const style = styleObj || {};
    const elementId = (this.getSilexId(element) as SilexId);

    if (!deleteStyle) {
      // styles of sections are special
      if (this.model.element.isSection(element)) {
        // do not apply width to sections
        delete style.width;

        // apply height to section content and not section itself
        const contentElement = (this.model.element.getContentNode(element) as HTMLElement);
        const contentStyle = this.getStyle(contentElement, isMobile) || {};
        if (style['min-height'] && style['min-height'] !== contentStyle['min-height']) {
          contentStyle['min-height'] = style['min-height'];
          this.setStyle(contentElement, contentStyle, isMobile);
        }

        // do not apply min-height to the section itself
        delete style['min-height'];
      } else if (style.width
          && this.model.element.isSectionContent(element)
          && !this.view.workspace.getMobileEditor()) {
        // set website width
        const width = parseInt(style.width);
        if (!!width && this.model.head.getWebsiteWidth() !== width) {
          this.model.head.setWebsiteWidth(width);
        }
        delete style.width;
      }
    }

    // store in JSON
    const targetObj = isMobile ? this.mobileStylesObj : this.stylesObj;
    if (deleteStyle) {
      delete targetObj[elementId];
    } else {
      targetObj[elementId] = style as CssRule;
    }

    // find the index of the rule for the given element
    const cssRuleObject = this.findCssRule(elementId, isMobile);

    // update or create the rule
    if (cssRuleObject) {
      this.styleSheet.deleteRule(cssRuleObject.index);
    }
    // convert style to string
    if (!deleteStyle) {
      // we use the class name because elements have their ID as a css class too
      const styleStr = '.' + elementId + '{' + Style.styleToString(style) + '} ';
      if (isMobile) {
        // add the rule to the dom to see the changes, mobile rules after
        // desktop ones
        this.styleSheet.insertRule(this.addMediaQuery(styleStr), this.styleSheet.cssRules.length);
      } else {
        this.styleSheet.insertRule(styleStr, 0);
      }
    }
    this.view.stageWrapper.redraw();
  }

  /**
   * get / set the css style of an element
   * @param opt_isMobile defaults to the global setting of silex.view.Workspace
   * @return a clone of the style object
   */
  getStyle(element: HTMLElement, opt_isMobile?: boolean): CssRule {
    const elementId = (this.getSilexId(element) as SilexId);
    const isMobile = opt_isMobile != null  ? opt_isMobile :  this.view.workspace.getMobileEditor();
    const targetObj = (isMobile ? this.mobileStylesObj : this.stylesObj as SilexData);
    const style = (targetObj[elementId] as CssRule);
    if (!!style) {
      // convert to obj (also makes it a copy we can change)
      const clone = (JSON.parse(JSON.stringify(style)) as CssRule);

      // styles of sections are special
      // the min-height of the section is stored on its content container
      if (this.model.element.isSection(element)) {
        // min-height of sections is the min-height of section content
        const contentElement = (this.model.element.getContentNode(element) as HTMLElement);
        if (contentElement === element) {
          console.warn('This section has no content, how is this possible?', element);
        } else {
          const contentStyle = this.getStyle(contentElement, isMobile);
          if (contentStyle) {
            clone['min-height'] = contentStyle['min-height'];
          }
        }
      }
      if (this.model.element.isSection(element) || this.model.element.isSectionContent(element)) {
        clone.width = this.model.head.getWebsiteWidth() + 'px';
      }
      return clone;
    }
    return null;
  }

  /**
   * @return null if not found
   */
  findCssRule(elementId: string, isMobile: boolean): CSSRuleInfo {
    // find the rule for the given element
    for (let idx = 0; idx < this.styleSheet.cssRules.length; idx++) {
      const cssRule = this.styleSheet.cssRules[idx] as any; // FIXME: should be CSSRule ?
      // we use the class name because elements have their ID as a css class too
      if ((isMobile === false && cssRule.selectorText === '.' + elementId) ||
          (cssRule.media && cssRule.cssRules && cssRule.cssRules[0] &&
              cssRule.cssRules[0].selectorText === '.' + elementId)) {
        return {
          rule: cssRule,
          parent: this.styleSheet,
          index: idx,
        };
      }
    }
    return null;
  }

  /**
   * @param doc docment of the iframe containing the website
   * @return the string defining all elements styles
   */
  getAllStyles(doc: Document): string {
    const elements = doc.querySelectorAll('body, .' + Constants.EDITABLE_CLASS_NAME);
    let allStyles = '';
    for (const element of elements) {
      const elementId = (this.getSilexId(element as HTMLElement) as SilexId);

      // desktop
      if (this.stylesObj[elementId]) {
        const styleStr = Style.styleToString(
            this.stylesObj[elementId], '\n    ');
        allStyles += '.' + elementId + ' {' + styleStr + '\n}\n';
      }

      // mobile
      if (this.mobileStylesObj[elementId]) {
        const styleStr = Style.styleToString(
            this.mobileStylesObj[elementId], '\n    ');
        allStyles +=
            this.addMediaQuery('.' + elementId + ' {' + styleStr + '\n}\n');
      }
    }
    return allStyles;
  }

  /**
   * add a media query around the style string
   * will make the style mobile-only
   */
  addMediaQuery(styleStr: string) {
    return '@media ' + Property.MOBILE_MEDIA_QUERY + '{' + styleStr + '}';
  }

  /**
   * compute the bounding box of the given elements
   * it takes into account only the elements which have top, left, width and
   * height set in px
   * @return the bounding box containing all the elements
   */
  getBoundingBox(elements: HTMLElement[]): {top?: number, left?: number, width?: number, height?: number} {
    // compute the positions and sizes, which may end up to be NaN or a number
    let top = NaN; let left = NaN; let right = NaN; let bottom = NaN;

    // browse all elements and compute the containing rect
    elements.forEach((element) => {
      // retrieve the styles strings (with "px")
      let elementStyle = this.getStyle(element, false);
      if (!elementStyle) {
        elementStyle = {
          'top': '',
          'left': '',
          'width': '',
          'height': '',
          'min-height': '',
        };
      } else {
        if (!elementStyle.top) {
          elementStyle.top = '';
        }
        if (!elementStyle.left) {
          elementStyle.left = '';
        }
        if (!elementStyle.width) {
          elementStyle.width = '';
        }
        if (!elementStyle.height) {
          elementStyle.height = '';
        }
      }

      // in mobile editor, if a mobile style is set use it
      if (this.view.workspace.getMobileEditor()) {
        const mobileStyle = this.getStyle(element, true);
        if (mobileStyle != null ) {
          if (!!mobileStyle.top) {
            elementStyle.top = mobileStyle.top;
          }
          if (!!mobileStyle.left) {
            elementStyle.left = mobileStyle.left;
          }
          if (!!mobileStyle.width) {
            elementStyle.width = mobileStyle.width;
          }
          if (!!mobileStyle.height) {
            elementStyle.height = mobileStyle.height;
          }
        }
      }

      // compute the styles numerical values, which may end up to be NaN or a
      // number
      const elementMinWidth = elementStyle['min-width'] ?
          parseFloat(elementStyle['min-width'].substr(
              0, elementStyle['min-width'].indexOf('px'))) :
          null;
      const elementWidth = Math.max(
          elementMinWidth || 0,
          parseFloat(elementStyle.width.substr(
              0, elementStyle.width.indexOf('px'))));
      const elementMinHeight = elementStyle['min-height'] ?
          parseFloat(elementStyle['min-height'].substr(
              0, elementStyle['min-height'].indexOf('px'))) :
          null;
      const elementHeight = Math.max(
          elementMinHeight || 0,
          parseFloat(elementStyle.height.substr(
              0, elementStyle.height.indexOf('px'))) ||
              0);
      const elementTop = parseFloat(
          elementStyle.top.substr(0, elementStyle.top.indexOf('px')));
      const elementLeft = parseFloat(
          elementStyle.left.substr(0, elementStyle.left.indexOf('px')));
      const elementRight = (elementLeft || 0) + elementWidth;
      const elementBottom = (elementTop || 0) + elementHeight;

      // take the smallest top and left and the bigger bottom and rigth
      top = isNaN(top) ? elementTop :
                         Math.min(top, isNaN(elementTop) ? top : elementTop);
      left = isNaN(left) ?
          elementLeft :
          Math.min(left, isNaN(elementLeft) ? left : elementLeft);
      bottom = isNaN(bottom) ?
          elementBottom :
          Math.max(bottom, isNaN(elementBottom) ? bottom : elementBottom);
      right = isNaN(right) ?
          elementRight :
          Math.max(right, isNaN(elementRight) ? right : elementRight);
    });

    // no value for NaN results
    const res: {top?: number, left?: number, width?: number, height?: number} = {};
    if (!isNaN(top)) {
      res.top = top;
    }
    if (!isNaN(left)) {
      res.left = left;
    }
    if (!isNaN(bottom)) {
      res.height = bottom - (top || 0);
    }
    if (!isNaN(right)) {
      res.width = right - (left || 0);
    }
    return res;
  }

  /**
   * get / set the data associated with an ID
   * if opt_prodotypeData is null this data set will removed
   */
  private setProdotypeData(
      id: SilexId, type: ProdotypeTypes,
      opt_prodotypeData?: ComponentData|StyleData) {
    // store in object
    if (opt_prodotypeData) {
      this.prodotypeDataObj[type][id] = opt_prodotypeData;
    } else {
      delete this.prodotypeDataObj[type][id];
    }
    setTimeout(() => {
      this.view.stageWrapper.redraw();
    }, 0);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  private getProdotypeData(id: SilexId, type: ProdotypeTypes): ComponentData
      |StyleData {
    const res = this.prodotypeDataObj[type][id];
    if (res) {
      const clone =
          (JSON.parse(JSON.stringify(res)) as ComponentData | StyleData);

      // clone the object
      return clone;
    }
    return null;
  }

  /**
   * get / set the data associated with an element
   * if opt_componentData is null this will remove the rule
   */
  private setElementData(
      element: HTMLElement, type: ProdotypeTypes,
      opt_componentData?: ComponentData|StyleData) {
    // a section's container content can not be a component, but the section
    // itself may be
    element = this.model.element.noSectionContent(element);

    // get the internal ID
    const elementId = (this.getSilexId(element) as SilexId);

    // store in object
    this.setProdotypeData(elementId, type, opt_componentData);
  }

  /**
   * get / set the data associated with an element
   * @return a clone of the data object
   */
  private getElementData(element: HTMLElement, type: ProdotypeTypes):
      ComponentData|StyleData {
    // a section's container content can not be a component, but the section
    // itself may be
    element = this.model.element.noSectionContent(element);

    // get the internal ID
    const elementId = (this.getSilexId(element) as SilexId);

    // returns value of object
    return this.getProdotypeData(elementId, type);
  }
}
