<?php
/* SVN FILE: $Id$ */
/**
 * SassRuleNode class file.
 * @author      Chris Yates <chris.l.yates@gmail.com>
 * @copyright   Copyright (c) 2010 PBM Web Development
 * @license      http://phamlp.googlecode.com/files/license.txt
 * @package      PHamlP
 * @subpackage  Sass.tree
 */

/**
 * SassRuleNode class.
 * Represents a CSS rule.
 * @package      PHamlP
 * @subpackage  Sass.tree
 */
class SassRuleNode extends SassNode
{
  const MATCH = '/^(.+?)(?:\s*\{)?$/';
  const SELECTOR = 1;
  const CONTINUED = ',';

  /**
   * @const string that is replaced with the parent node selector
   */
  const PARENT_REFERENCE = '&';

  /**
   * @var array selector(s)
   */
  private $selectors = array();

  /**
   * @var array parent selectors
   */
  private $parentSelectors = array();

  /**
   * @var boolean whether the node expects more selectors
   */
  private $isContinued;

  /**
   * SassRuleNode constructor.
   * @param object $token source token
   * @return SassRuleNode
   */
  public function __construct($token)
  {
    parent::__construct($token);
    preg_match(self::MATCH, $token->source, $matches);
    $this->addSelectors($matches[SassRuleNode::SELECTOR]);
  }

  /**
   * Adds selector(s) to the rule.
   * If the selectors are to continue for the rule the selector must end in a comma
   * @param string $selectors selector
   * @param boolean $explode
   */
  public function addSelectors($selectors, $explode = true)
  {
    $this->isContinued = substr($selectors, -1) === self::CONTINUED;
    $this->selectors = array_merge($this->selectors, $explode ? $this->explode($selectors) : $selectors);
  }

  /**
   * Returns a value indicating if the selectors for this rule are to be continued.
   *
   * @return bool rue if the selectors for this rule are to be continued false if not
   */
  public function getIsContinued()
  {
    return $this->isContinued;
  }

  /**
   * Parse this node and its children into static nodes.
   * @param SassContext $context the context in which this node is parsed
   * @return array the parsed node and its children
   */
  public function parse($context)
  {
    $node = clone $this;
    $node->selectors = $this->resolveSelectors($context);
    $node->children = $this->parseChildren($context);

    return array($node);
  }

  /**
   * Render this node and its children to CSS.
   * @return string the rendered node
   */
  public function render()
  {
    $this->extend();
    $rules = '';
    $properties = array();

    foreach ($this->children as $child) {
      $child->parent = $this;
      if ($child instanceof SassRuleNode) {
        $rules .= $child->render();
      } else {
        $properties[] = $child->render();
      }
    }

    return $this->getRenderer()->renderRule($this, $properties, $rules);
  }

  /**
   * Extend this nodes selectors
   * $extendee is the subject of the @extend directive
   * $extender is the selector that contains the @extend directive
   * $selector a selector or selector sequence that is to be extended
   */
  public function extend()
  {
    foreach ($this->root->getExtenders() as $extendee => $extenders) {
      if ($this->isPsuedo($extendee)) {
        $extendee = explode(':', $extendee);
        $pattern = preg_quote($extendee[0]).'((\.[-\w]+)*):'.preg_quote($extendee[1]);
      } else {
        $pattern = preg_quote($extendee);
      }

      foreach (preg_grep('/'.$pattern.'/', $this->selectors) as $selector) {
        foreach ($extenders as $extender) {
          # first if establishes that we are using a placeholder and the extendee begins with a tag
          if ($extendee{0} == '%' && $selector{0} != '%' && preg_match('/(^| )[a-zA-Z][^%]*' . preg_quote($extendee) . '([^a-z0-9_-]|$)/', $selector)) {
            # the second if establishes that the extender is a tag rather than a class/id
            $zero = ord(strtolower(substr($extender, 0, 1))); // cheaper than regex
            if ($zero >= 97 && $zero <= 122) {
              continue;
            }
          }
          if (is_array($extendee)) {
            $this->selectors[] = preg_replace('/(.*?)'.$pattern.'([^a-zA-Z0-9_-]|$)/', '$1' . $extender . '$2', $selector);
          } elseif ($this->isSequence($extender) || $this->isSequence($selector)) {
            $this->selectors = array_merge($this->selectors, $this->mergeSequence($extender, $extendee, $selector));
          } else {
            $this->selectors[] = str_replace($extendee, $extender, $selector);
          }
        }
      }
      $this->selectors = array_unique($this->selectors);
    }
  }

  /**
   * Tests whether the selector is a psuedo selector
   * @param string $selector selector to test
   * @return boolean true if the selector is a psuedo selector, false if not
   */
  private function isPsuedo($selector)
  {
    return strpos($selector, ':') !== false;
  }

  /**
   * Tests whether the selector is a sequence selector
   * @param string $selector selector to test
   * @return boolean true if the selector is a sequence selector, false if not
   */
  private function isSequence($selector)
  {
    return strpos($selector, ' ') !== false;
  }

  public function isPlaceholder($selector)
  {
    return strpos($selector, '%') !== false && !preg_match("/^[\d]+%$/", $selector);
  }

  /**
   * Merges selector sequences
   * @param string $extender the extender selector
   * @param string $extendee
   * @param string $selector selector to extend
   * @return array the merged sequences
   */
  private function mergeSequence($extender, $extendee, $selector)
  {
    // if it's a placeholder, be lazy. Needs tests.
    if ($extendee[0] == '%') {
      // need to stop things like a%foo accepting div { @extend %foo }
      return array(str_replace($extendee, $extender,  $selector));
    }

    $extender = explode(' ', $extender);
    $end = array_pop($extender);
    $selector = explode(' ', $selector);
    array_pop($selector);

    $common = array();
    if (count($extender) && count($selector)) {
      while (trim($extender[0]) === trim($selector[0])) {
        $common[] = array_shift($selector);
        array_shift($extender);
        if (!count($extender)) {
          break;
        }
      }
    }

    $beginning = (!empty($common) ? join(' ', $common) . ' ' : '');

    # Richard Lyon - 2011-10-25 - removes duplicates by uniquing and trimming.
    # regex removes whitespace from start and and end of string as well as removing
    # whitespace following whitespace. slightly quicker than a trim and simpler replace

    return array_unique(array(
      preg_replace('/(^\s+|(\s)\s+|\s+$)/', '$2', $beginning.join(' ', $selector).' '.join(' ', $extender). ' ' . $end),
      preg_replace('/(^\s+|(\s)\s+|\s+$)/', '$2', $beginning.join(' ', $extender).' '.join(' ', $selector). ' ' . $end)
    ));
  }

  /**
   * Returns the selectors
   * @return array selectors
   */
  public function getSelectors()
  {
    return $this->selectors;
  }

  /**
   * Resolves selectors.
   * Interpolates SassScript in selectors and resolves any parent references or
   * appends the parent selectors.
   * @param SassContext $context the context in which this node is parsed
   *
   * Change: 7/Dec/11 - change to make selector ordering conform to Ruby compiler.
   * @return array
   */
  public function resolveSelectors($context)
  {
    $resolvedSelectors = $normalSelectors = array();
    $this->parentSelectors = $this->getParentSelectors($context);

    foreach ($this->selectors as $selector) {
      $selector = $this->interpolate($selector, $context);
      $selectors = SassList::_build_list($selector);

      foreach ($selectors as $selector_inner) {
	    $selector_inner = trim($selector_inner, ' \'"'); // strip whitespace and quotes, just-in-case.
        if ($this->hasParentReference($selector_inner)) {
          $resolvedSelectors = array_merge($resolvedSelectors, $this->resolveParentReferences($selector_inner, $context));
        } else {
          $normalSelectors[] = $selector_inner;
        }
      }
    } // foreach

    // merge with parent selectors
    if ($this->parentSelectors) {
      $return = array();
      foreach ($this->parentSelectors as $parent) {
        foreach ($normalSelectors as $selector) {
          $spacer = (substr($selector, 0, 1) == '[') ? '' : ' ';

          $return[] = $parent . $spacer . $selector;
        }
      }
      $normalSelectors = $return;
    }

    return array_merge($normalSelectors, $resolvedSelectors);
  }

	/**
	 * Returns the parent selector(s) for this node.
	 * This in an empty array if there is no parent selector.
	 * @param mixed $context
	 * @return array the parent selector for this node
	 */
  protected function getParentSelectors($context)
  {
    $ancestor = $this->parent;
    while (!$ancestor instanceof SassRuleNode && $ancestor->hasParent()) {
      $ancestor = $ancestor->parent;
    }

    if ($ancestor instanceof SassRuleNode) {
      return $ancestor->resolveSelectors($context);
    }

    return array();
  }

  /**
   * Returns the position of the first parent reference in the selector.
   * If there is no parent reference in the selector this function returns
   * boolean FALSE.
   * Note that the return value may be non-Boolean that evaluates to FALSE,
   * i.e. 0. The return value should be tested using the === operator.
   * @param string $selector selector to test
   * @return mixed integer: position of the the first parent reference,
   * boolean: false if there is no parent reference.
   */
  private function parentReferencePos($selector)
  {
    $inString = '';
    for ($i = 0, $l = strlen($selector); $i < $l; $i++) {
      $c = $selector[$i];
      if ($c === self::PARENT_REFERENCE && empty($inString)) {
        return $i;
      } elseif (empty($inString) && ($c === '"' || $c === "'")) {
        $inString = $c;
      } elseif ($c === $inString) {
        $inString = '';
      }
    }

    return false;
  }

  /**
   * Determines if there is a parent reference in the selector
   * @param string $selector selector
   * @return boolean true if there is a parent reference in the selector
   */
  private function hasParentReference($selector)
  {
    return $this->parentReferencePos($selector) !== false;
  }

  /**
   * Resolves parent references in the selector
   * @param string $selector selector
   * @param mixed $context
   * @throws SassRuleNodeException
   * @return string selector with parent references resolved
   */
  private function resolveParentReferences($selector, $context)
  {
    $resolvedReferences = array();
    if (!count($this->parentSelectors)) {
      throw new SassRuleNodeException('Can not use parent selector (' . self::PARENT_REFERENCE . ') when no parent selectors', $this);
    }
    foreach ($this->getParentSelectors($context) as $parentSelector) {
      $resolvedReferences[] = str_replace(self::PARENT_REFERENCE, $parentSelector, $selector);
    }

    return $resolvedReferences;
  }

  /**
   * Explodes a string of selectors into an array.
   * We can't use PHP::explode as this will potentially explode attribute
   * matches in the selector, e.g. div[title="some,value"] and interpolations.
   * @param string $string selectors
   * @return array selectors
   */
  private function explode($string)
  {
    $selectors = array();
    $inString = false;
    $interpolate = false;
    $selector = '';

    for ($i = 0, $l = strlen($string); $i < $l; $i++) {
      $c = $string[$i];
      if ($c === self::CONTINUED && !$inString && !$interpolate) {
        $selectors[] = trim($selector);
        $selector = '';
      } else {
        $selector .= $c;
        if ($c === '"' || $c === "'") {
          do {
            $_c = $string[++$i];
            $selector .= $_c;
          } while ($_c !== $c && isset($string[$i+1]));
        } elseif ($c === '#' && $string[$i+1] === '{') {
          do {
            $c = $string[++$i];
            $selector .= $c;
          } while ($c !== '}');
        }
      }
    }

    if (!empty($selector)) {
      $selectors[] = trim($selector);
    }

    return $selectors;
  }
}
