<?php
/* SVN FILE: $Id$ */
/**
 * SassNumber 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.script.literals
 */

require_once('SassLiteral.php');

/**
 * SassNumber class.
 * Provides operations and type testing for Sass numbers.
 * Units are of the passed value are converted the those of the class value
 * if it has units. e.g. 2cm + 20mm = 4cm while 2 + 20mm = 22mm.
 * @package      PHamlP
 * @subpackage  Sass.script.literals
 */
class SassNumber extends SassLiteral {
  /**
   * Regx for matching and extracting numbers
   */
  const MATCH = '/^((?:-)?(?:\d*\.)?\d+)(([a-z%]+)(\s*[\*\/]\s*[a-z%]+)*)?/i';
  // const MATCH = '/^(?!\d+px\/)((?:-)?(?:\d.)?\d+)(([a-z%]+)(\s[*\/]\s[a-z%]+))?/i';
  const VALUE = 1;
  const UNITS = 2;
  /**
   * The number of decimal digits to round to.
   * If the units are pixels the result is always
   * rounded down to the nearest integer.
   */
  const PRECISION = 3;

  /**
   * @var array Conversion factors for units using inches as the base unit
   * (only because pt and pc are expressed as fraction of an inch, so makes the
   * numbers easy to understand).
   * Conversions are based on the following
   * in: inches — 1 inch = 2.54 centimeters
   * cm: centimeters
   * mm: millimeters
   * pc: picas — 1 pica = 12 points
   * pt: points — 1 point = 1/72nd of an inch
   */
  static private $unitConversion = array(
    'in' => 1,
    'cm' => 2.54,
    'mm' => 25.4,
    'pc' => 6,
    'pt' => 72,
    'px' => 96
  );
  static private $validUnits = array(
    'in', 'cm', 'mm', 'pc', 'pt', 'em', 'rem', 'ex', 'px', '%', 's', 'deg'
  );

  /**
   * @var array numerator units of this number
   */
  private $numeratorUnits = array();

  /**
   * @var array denominator units of this number
   */
  private $denominatorUnits = array();

  /**
   * @var boolean whether this number is in an expression or a literal number
   * Used to determine whether division should take place
   */
  public $inExpression = true;

  /**
   * class constructor.
   * Sets the value and units of the number.
   * @param string number
   * @return SassNumber
   */
  public function __construct($value) {
    preg_match(self::MATCH, $value, $matches);

    $matches += array(null,null,'','');

    $this->value = $matches[self::VALUE];
    if (!empty($matches[self::UNITS])) {
      $units = explode('/', $matches[self::UNITS]);
      $numeratorUnits = $denominatorUnits = array();

      foreach (explode('*', $units[0]) as $unit) {
        $numeratorUnits[] = trim($unit);
      }
      if (isset($units[1])) {
        foreach (explode('*', $units[1]) as $unit) {
          $denominatorUnits[] = trim($unit);
        }
      }
      $units = $this->removeCommonUnits($numeratorUnits, $denominatorUnits);
      $this->numeratorUnits = $units[0];
      $this->denominatorUnits = $units[1];
    }
  }

  /**
   * Adds the value of other to the value of this
   * @param mixed SassNumber|SassColour: value to add
   * @return mixed SassNumber if other is a SassNumber or
   * SassColour if it is a SassColour
   */
  public function op_plus($other) {
    if ($other instanceof SassColour) {
      return $other->op_plus($this);
    }
    else if ($other instanceOf SassString) {
      $other = clone $other;
      $other->value = $this->value . $other->value;
      return $other;
    }
    elseif (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    else {
      $other = $this->convert($other);
      return new SassNumber(($this->value + $other->value).$this->units);
    }
  }

  /**
   * Unary + operator
   * @return SassNumber the value of this number
   */
  public function op_unary_plus() {
    return $this;
  }

  /**
   * Subtracts the value of other from this value
   * @param mixed SassNumber|SassColour: value to subtract
   * @return mixed SassNumber if other is a SassNumber or
   * SassColour if it is a SassColour
   */
  public function op_minus($other) {
    if ($other instanceof SassColour) {
      return $other->op_minus($this);
    }
    elseif (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    else {
      $other = $this->convert($other);
      return new SassNumber(($this->value - $other->value) . $this->units);
    }
  }

  /**
   * Unary - operator
   * @return SassNumber the negative value of this number
   */
  public function op_unary_minus() {
    return new SassNumber(($this->value * -1) . $this->units);
  }

  public function op_unary_concat() {
    return $this;
  }

  /**
   * Multiplies this value by the value of other
   * @param mixed SassNumber|SassColour: value to multiply by
   * @return mixed SassNumber if other is a SassNumber or
   * SassColour if it is a SassColour
   */
  public function op_times($other) {
    if ($other instanceof SassColour) {
      return $other->op_times($this);
    }
    elseif (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    else {
      return new SassNumber(($this->value * $other->value).$this->unitString(
        array_merge($this->numeratorUnits, $other->numeratorUnits),
        array_merge($this->denominatorUnits, $other->denominatorUnits)
      ));
    }
  }

  /**
   * Divides this value by the value of other
   * @param mixed SassNumber|SassColour: value to divide by
   * @return mixed SassNumber if other is a SassNumber or
   * SassColour if it is a SassColour
   */
  public function op_div($other) {
    if ($other instanceof SassColour) {
      return $other->op_div($this);
    }
    elseif (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    elseif ($this->inExpression || $other->inExpression) {
      return new SassNumber(($this->value / $other->value).$this->unitString(
        array_merge($this->numeratorUnits, $other->denominatorUnits),
        array_merge($this->denominatorUnits, $other->numeratorUnits)
      ));
    }
    else {
      return new SassNumber(($this->value / $other->value).$this->unitString(
        array_merge($this->numeratorUnits, $other->denominatorUnits),
        $this->denominatorUnits
      ));
    }
  }

  /**
   * The SassScript == operation.
   * @return SassBoolean SassBoolean object with the value true if the values
   * of this and other are equal, false if they are not
   */
  public function op_eq($other) {
    if (!$other instanceof SassNumber) {
      return new SassBoolean(false);
    }
    try {
      return new SassBoolean($this->value == $this->convert($other)->value);
    }
    catch (Exception $e) {
      return new SassBoolean(false);
    }
  }

  /**
   * The SassScript > operation.
   * @param sassLiteral the value to compare to this
   * @return SassBoolean SassBoolean object with the value true if the values
   * of this is greater than the value of other, false if it is not
   */
  public function op_gt($other) {
    if (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    return new SassBoolean($this->value > $this->convert($other)->value);
  }

  /**
   * The SassScript >= operation.
   * @param sassLiteral the value to compare to this
   * @return SassBoolean SassBoolean object with the value true if the values
   * of this is greater than or equal to the value of other, false if it is not
   */
  public function op_gte($other) {
    if (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    return new SassBoolean($this->value >= $this->convert($other)->value);
  }

  /**
   * The SassScript < operation.
   * @param sassLiteral the value to compare to this
   * @return SassBoolean SassBoolean object with the value true if the values
   * of this is less than the value of other, false if it is not
   */
  public function op_lt($other) {
    if (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    return new SassBoolean($this->value < $this->convert($other)->value);
  }

  /**
   * The SassScript <= operation.
   * @param sassLiteral the value to compare to this
   * @return SassBoolean SassBoolean object with the value true if the values
   * of this is less than or equal to the value of other, false if it is not
   */
  public function op_lte($other) {
    if (!$other instanceof SassNumber) {
      throw new SassNumberException('Number must be a number', SassScriptParser::$context->node);
    }
    return new SassBoolean($this->value <= $this->convert($other)->value);
  }

  /**
   * Takes the modulus (remainder) of this value divided by the value of other
   * @param string value to divide by
   * @return mixed SassNumber if other is a SassNumber or
   * SassColour if it is a SassColour
   */
  public function op_modulo($other) {
    if (!$other instanceof SassNumber || !$other->isUnitless()) {
      throw new SassNumberException('Number must be a unitless number', SassScriptParser::$context->node);
    }
    $this->value %= $this->convert($other)->value;
    return $this;
  }

  /**
   * Converts values and units.
   * If this is a unitless numeber it will take the units of other; if not
   * other is coerced to the units of this.
   * @param SassNumber the other number
   * @return SassNumber the other number with its value and units coerced if neccessary
   * @throws SassNumberException if the units are incompatible
   */
  private function convert($other) {
    if ($this->isUnitless()) {
      $this->numeratorUnits = $other->numeratorUnits;
      $this->denominatorUnits = $other->denominatorUnits;
    }
    else {
      $other = $other->coerce($this->numeratorUnits, $this->denominatorUnits);
    }
    return $other;
  }

  /**
   * Returns the value of this number converted to other units.
   * The conversion takes into account the relationship between e.g. mm and cm,
   * as well as between e.g. in and cm.
   *
   * If this number is unitless, it will simply return itself with the given units.
   * @param array $numeratorUnits
   * @param array $denominatorUnits
   * @return SassNumber
   */
  public function coerce($numeratorUnits, $denominatorUnits) {
    return new SassNumber(($this->isUnitless() ?
      $this->value : $this->value *
      $this->coercionFactor($this->numeratorUnits, $numeratorUnits) /
      $this->coercionFactor($this->denominatorUnits, $denominatorUnits)
    ) . join(' * ', $numeratorUnits) . (!empty($denominatorUnits) ? ' / ' . join(' * ', $denominatorUnits) : ''));
  }

  /**
   * Calculates the corecion factor to apply to the value
   * @param array units being converted from
   * @param array units being converted to
   * @return float the coercion factor to apply
   */
  private function coercionFactor($fromUnits, $toUnits) {
    $units = $this->removeCommonUnits($fromUnits, $toUnits);
    $fromUnits = $units[0];
    $toUnits = $units[1];

    while (count($fromUnits) > count($toUnits)) {
      $toUnits[] = 'in';
    }

    if (sizeof($fromUnits) !== sizeof($toUnits) || !$this->areConvertable(array_merge($fromUnits, $toUnits))) {
      throw new SassNumberException("Incompatible units: '" . join(' * ', $fromUnits) . "' and '" . join(' * ', $toUnits) . "'", SassScriptParser::$context->node);
    }

    $coercionFactor = 1;
    foreach ($fromUnits as $i=>$from) {
      if (array_key_exists($i, $toUnits) && array_key_exists($toUnits[$i], self::$unitConversion)) {
        $coercionFactor *= self::$unitConversion[$toUnits[$i]] / self::$unitConversion[$from];
      }
      else {
        throw new SassNumberException("Incompatible units: '" . join(' * ', $fromUnits) . "' and '" . join(' * ', $toUnits) . "'", SassScriptParser::$context->node);
      }
    }
    return $coercionFactor;
  }

  /**
   * Returns a value indicating if all the units are capable of being converted
   * @param array units to test
   * @return boolean true if all units can be converted, false if not
   */
  private function areConvertable($units) {
    $convertable = array_keys(self::$unitConversion);
    foreach ($units as $unit) {
      if (!in_array($unit, $convertable)) {
        return false;
      }
    }
    return true;
  }

  /**
   * Removes common units from each set.
   * We don't use array_diff because we want (for eaxmple) mm*mm/mm*cm to
   * end up as mm/cm.
   * @param array first set of units
   * @param array second set of units
   * @return array both sets of units with common units removed
   */
  private function removeCommonUnits($u1, $u2) {
    $_u1 = array();
    while (!empty($u1)) {
      $u = array_shift($u1);
      $i = array_search($u, $u2);
      if ($i !== false) {
        unset($u2[$i]);
      }
      else {
        $_u1[] = $u;
      }
    }
    return (array($_u1, $u2));
  }

  /**
   * Returns a value indicating if this number is unitless.
   * @return boolean true if this number is unitless, false if not
   */
  public function isUnitless() {
    return empty($this->numeratorUnits) && empty($this->denominatorUnits);
  }

  /**
   * Returns a value indicating if this number has units.
   * @return boolean true if this number has, false if not
   */
  public function hasUnits() {
    return !$this->isUnitless();
  }

  /**
   * Returns a value indicating if this number has units that can be represented
   * in CSS.
   * @return boolean true if this number has units that can be represented in
   * CSS, false if not
   */
  public function hasLegalUnits() {
    return (empty($this->numeratorUnits) || count($this->numeratorUnits) === 1) && empty($this->denominatorUnits);
  }

  /**
   * Returns a string representation of the units.
   * @return string the units
   */
  public function unitString($numeratorUnits, $denominatorUnits) {
    foreach ($numeratorUnits as $i => $unit) {
      if (!in_array($unit, self::$validUnits)) {
        unset($numeratorUnits[$i]);
      }
    }
    foreach ($denominatorUnits as $i => $unit) {
      if (!in_array($unit, self::$validUnits)) {
        unset($denominatorUnits[$i]);
      }
    }
    return join(' * ', $numeratorUnits) . (!empty($denominatorUnits) ? ' / ' . join(' * ', $denominatorUnits) : '');
  }

  /**
   * Returns the units of this number.
   * @return string the units of this number
   */
  public function getUnits() {
    return $this->unitString($this->numeratorUnits, $this->denominatorUnits);
  }

  /**
   * Returns the denominator units of this number.
   * @return string the denominator units of this number
   */
  public function getDenominatorUnits() {
    return join(' * ', $this->denominatorUnits);
  }

  /**
   * Returns the numerator units of this number.
   * @return string the numerator units of this number
   */
  public function getNumeratorUnits() {
    return join(' * ', $this->numeratorUnits);
  }

  /**
   * Returns a value indicating if this number can be compared to other.
   * @return boolean true if this number can be compared to other, false if not
   */
  public function isComparableTo($other) {
    try {
      $this->op_plus($other);
      return true;
    }
    catch (Exception $e) {
      return false;
    }
  }

  /**
   * Returns a value indicating if this number is an integer.
   * @return boolean true if this number is an integer, false if not
   */
  public function isInt() {
    return $this->value % 1 === 0;
  }

  /**
   * Returns the value of this number.
   * @return float the value of this number.
   */
  public function getValue() {
    return $this->value;
  }

  /**
   * Returns the integer value.
    * @return integer the integer value.
    * @throws SassNumberException if the number is not an integer
   */
  public function toInt() {
    if  (!$this->isInt()) {
      throw new SassNumberException('Not an integer: ' . $this->value, SassScriptParser::$context->node);
    }
    return intval($this->value);
  }

  /**
   * Converts the number to a string with it's units if any.
   * If the units are px the result is rounded down to the nearest integer,
   * otherwise the result is rounded to the specified precision.
    * @return string number as a string with it's units if any
   */
  public function toString() {
    if (!isset($this->units)) {
      $this->units = $this->getUnits();
    }
    return ($this->units == 'px' ? floor($this->value) : str_replace(',','.',round($this->value, self::PRECISION))) . $this->units;
  }

  /**
   * Returns a value indicating if a token of this type can be matched at
   * the start of the subject string.
   * @param string the subject string
   * @return mixed match at the start of the string or false if no match
   */
  public static function isa($subject) {
    return (preg_match(self::MATCH, $subject, $matches) ? $matches[0] : false);
  }
}
