<?php
namespace MailPoetVendor;

if (!defined('ABSPATH')) exit;


use MailPoet\Util\pQuery\DomNode;
use MailPoet\Util\pQuery\pQuery;
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;

/*
  Copyright 2013-2014, François-Marie de Jouvencel

  This program is free software: you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

/*
* A class to inline CSS.
*
* It honours !important attributes and doesn't choke on complex styles.
*
*
*/

class CSS {
  /**
   * @param string $contents
   * @return DomNode
   */
  function inlineCSS($contents) {
    $html = pQuery::parseStr($contents);

    if (!$html instanceof DomNode) {
      throw new \InvalidArgumentException('Error parsing contents.');
    }

    $css_blocks = '';

    // Find all <style> blocks and cut styles from them (leaving media queries)
    foreach ($html->query('style') as $style) {
      list($_css_to_parse, $_css_to_keep) = $this->splitMediaQueries($style->getInnerText());
      $css_blocks .= $_css_to_parse;
      if (!empty($_css_to_keep)) {
        $style->setInnerText($_css_to_keep);
      } else {
        $style->setOuterText('');
      }
    }

    $raw_css = '';
    if (!empty($css_blocks)) {
      $raw_css .= $css_blocks;
    }

    // Get the CSS rules by decreasing specificity (the most specific rule first).
    // This is an array with, amongst other things, the keys 'properties', which hold the CSS properties
    // and the 'selector', which holds the CSS selector
    $rules = $this->parseCSS($raw_css);
    $nodes_map = [];

    // We loop over each rule by increasing order of specificity, find the nodes matching the selector
    // and apply the CSS properties
    foreach ($rules as $rule) {
      if (!isset($nodes_map[$rule['selector']])) {
        $nodes_map[$rule['selector']] = $html->query($rule['selector']);
      }
      foreach ($nodes_map[$rule['selector']] as $node) {
        // I'm leaving this for debug purposes, it has proved useful.
        /*
        if ($node->already_styled === 'yes')
        {
          echo "<PRE>";
          echo "Rule:\n";
          print_r($rule);
          echo "\n\nOld style:\n";
          echo $node->style."\n";
          print_r($this->styleToArray($node->style));
          echo "\n\nNew style:\n";
          print_r(array_merge($this->styleToArray($node->style), $rule['properties']));
          echo "</PRE>";
          die();
        }//*/

        // Unserialize the style array, merge the rule's CSS into it...
        $nodeStyles = $this->styleToArray($node->style);
        $style = array_merge($rule['properties'], $nodeStyles);

        // And put the CSS back as a string!
        $node->style = $this->arrayToStyle($style);

        // I'm leaving this for debug purposes, it has proved useful.
        /*
        if ($rule['selector'] === 'table.table-recap td')
        {
          $node->already_styled = 'yes';
        }//*/
      }
    }

    // Now a tricky part: do a second pass with only stuff marked !important
    // because !important properties do not care about specificity, except when fighting
    // against another !important property
    // We need to start with a rule with lowest specificity
    $rules = array_reverse($rules);
    foreach ($rules as $rule) {
      foreach ($rule['properties'] as $key => $value) {
        if (strpos($value, '!important') === false) {
          continue;
        }
        foreach ($nodes_map[$rule['selector']] as $node) {
          $style = $this->styleToArray($node->style);
          $style[$key] = $value;
          $node->style = $this->arrayToStyle($style);
          // remove all !important tags (inlined styles take precedent over others anyway)
          $node->style = str_replace("!important", "", $node->style);
        }
      }
    }

    return $html;
  }

  function parseCSS($text) {
    $css  = new csstidy();
    $css->settings['compress_colors'] = false;
    $css->parse($text);

    $rules    = [];
    $position   = 0;

    foreach ($css->css as $declarations) {
      foreach ($declarations as $selectors => $properties) {
        foreach (explode(",", $selectors) as $selector) {
          $rules[] = [
            'position'    => $position,
            'specificity'   => $this->calculateCSSSpecifity($selector),
            'selector'    => $selector,
            'properties'  => $properties,
          ];
        }

        $position += 1;
      }
    }

    usort($rules, function($a, $b) {
      if ($a['specificity'] > $b['specificity']) {
        return -1;
      } else if ($a['specificity'] < $b['specificity']) {
        return 1;
      } else {
        if ($a['position'] > $b['position']) {
          return -1;
        } else {
          return 1;
        }
      }
    });

    return $rules;
  }

  /*
  * Merges two CSS inline styles strings into one.
  * If both styles defines same property the property from second styles will be used.
  */
  function mergeInlineStyles($styles_1, $styles_2) {
    $merged_styles = array_merge($this->styleToArray($styles_1), $this->styleToArray($styles_2));
    return $this->arrayToStyle($merged_styles);
  }

  private function splitMediaQueries($css) {
    $start = 0;
    $queries = '';

    while (($start = strpos($css, "@media", $start)) !== false) {
      // stack to manage brackets
      $s = [];

      // get the first opening bracket
      $i = strpos($css, "{", $start);

      // if $i is false, then there is probably a css syntax error
      if ($i !== false) {
        // push bracket onto stack
        array_push($s, $css[$i]);

        // move past first bracket
        $i++;

        while (!empty($s)) {
          // if the character is an opening bracket, push it onto the stack, otherwise pop the stack
          if ($css[$i] == "{") {
            array_push($s, "{");
          } else if ($css[$i] == "}") {
            array_pop($s);
          }

          $i++;
        }

        $queries .= substr($css, $start - 1, $i + 1 - $start) . "\n";
        $css = substr($css, 0, $start - 1) . substr($css, $i);
        $i = $start;
      }
    }

    return [$css, $queries];
  }

  /**
   * The following function fomes from CssToInlineStyles.php - here is the original licence FOR THIS FUNCTION
   *
   * CSS to Inline Styles class
   *
   * @author    Tijs Verkoyen <php-css-to-inline-styles@verkoyen.eu>
   * @version   1.2.1
   * @copyright Copyright (c), Tijs Verkoyen. All rights reserved.
   * @license   BSD License
   */

  private function calculateCSSSpecifity($selector) {
      // cleanup selector
    $selector = str_replace(['>', '+'], [' > ', ' + '], $selector);

      // init var
    $specifity = 0;

      // split the selector into chunks based on spaces
    $chunks = explode(' ', $selector);

      // loop chunks
    foreach ($chunks as $chunk) {
          // an ID is important, so give it a high specifity
      if (strstr($chunk, '#') !== false) $specifity += 100;

          // classes are more important than a tag, but less important then an ID
      elseif (strstr($chunk, '.')) $specifity += 10;

          // anything else isn't that important
      else $specifity += 1;
    }

      // return
    return $specifity;
  }

  /*
  * Turns a CSS style string (like: "border: 1px solid black; color:red")
  * into an array of properties (like: array("border" => "1px solid black", "color" => "red"))
  */
  private function styleToArray($str) {
    $str = EHelper::unescapeHtmlStyleAttr($str);
    $array = [];

    if (trim($str) === '') return $array;

    foreach (explode(';', $str) as $kv) {
      if ($kv === '') {
        continue;
      }
      list($selector, $rule) = explode(':', $kv, 2);
      $array[trim($selector)] = trim($rule);
    }

    return $array;
  }

  /*
  * Reverses what styleToArray does, see above.
  * array("border" => "1px solid black", "color" => "red") yields "border: 1px solid black; color:red"
  */
  private function arrayToStyle($array) {
    $parts = [];
    foreach ($array as $k => $v) {
      $parts[] = "$k:$v";
    }
    return EHelper::escapeHtmlStyleAttr(implode(';', $parts));
  }
}
