<?php

/*
 * Copyright 2012 Facebook, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

final class HeraldEngine {

  protected $rules = array();
  protected $results = array();
  protected $stack = array();
  protected $activeRule = null;

  protected $fieldCache = array();
  protected $object = null;

  public static function loadAndApplyRules(HeraldObjectAdapter $object) {
    $content_type = $object->getHeraldTypeName();
    $rules = HeraldRule::loadAllByContentTypeWithFullData(
      $content_type,
      $object->getPHID());

    $engine = new HeraldEngine();
    $effects = $engine->applyRules($rules, $object);
    $engine->applyEffects($effects, $object, $rules);

    return $engine->getTranscript();
  }

  public function applyRules(array $rules, HeraldObjectAdapter $object) {
    assert_instances_of($rules, 'HeraldRule');
    $t_start = microtime(true);

    $rules = mpull($rules, null, 'getID');

    $this->transcript = new HeraldTranscript();
    $this->transcript->setObjectPHID((string)$object->getPHID());
    $this->fieldCache = array();
    $this->results = array();
    $this->rules   = $rules;
    $this->object  = $object;

    $effects = array();
    foreach ($rules as $id => $rule) {
      $this->stack = array();
      try {
        if (($rule->getRepetitionPolicy() ==
             HeraldRepetitionPolicyConfig::FIRST) &&
            $rule->getRuleApplied($object->getPHID())) {
          // This rule is only supposed to be applied a single time, and it's
          // aleady been applied, so this is an automatic failure.
          $xscript = id(new HeraldRuleTranscript())
            ->setRuleID($id)
            ->setResult(false)
            ->setRuleName($rule->getName())
            ->setRuleOwner($rule->getAuthorPHID())
            ->setReason(
              "This rule is only supposed to be repeated a single time, ".
              "and it has already been applied."
            );
          $this->transcript->addRuleTranscript($xscript);
          $rule_matches = false;
        } else {
          $rule_matches = $this->doesRuleMatch($rule, $object);
        }
      } catch (HeraldRecursiveConditionsException $ex) {
        $names = array();
        foreach ($this->stack as $rule_id => $ignored) {
          $names[] = '"'.$rules[$rule_id]->getName().'"';
        }
        $names = implode(', ', $names);
        foreach ($this->stack as $rule_id => $ignored) {
          $xscript = new HeraldRuleTranscript();
          $xscript->setRuleID($rule_id);
          $xscript->setResult(false);
          $xscript->setReason(
            "Rules {$names} are recursively dependent upon one another! ".
            "Don't do this! You have formed an unresolvable cycle in the ".
            "dependency graph!");
          $xscript->setRuleName($rules[$rule_id]->getName());
          $xscript->setRuleOwner($rules[$rule_id]->getAuthorPHID());
          $this->transcript->addRuleTranscript($xscript);
        }
        $rule_matches = false;
      }
      $this->results[$id] = $rule_matches;

      if ($rule_matches) {
        foreach ($this->getRuleEffects($rule, $object) as $effect) {
          $effects[] = $effect;
        }
      }
    }

    $object_transcript = new HeraldObjectTranscript();
    $object_transcript->setPHID($object->getPHID());
    $object_transcript->setName($object->getHeraldName());
    $object_transcript->setType($object->getHeraldTypeName());
    $object_transcript->setFields($this->fieldCache);

    $this->transcript->setObjectTranscript($object_transcript);

    $t_end = microtime(true);

    $this->transcript->setDuration($t_end - $t_start);

    return $effects;
  }

  public function applyEffects(
    array $effects,
    HeraldObjectAdapter $object,
    array $rules) {
    assert_instances_of($effects, 'HeraldEffect');
    assert_instances_of($rules, 'HeraldRule');

    $this->transcript->setDryRun($object instanceof HeraldDryRunAdapter);

    $xscripts = $object->applyHeraldEffects($effects);
    foreach ($xscripts as $apply_xscript) {
      if (!($apply_xscript instanceof HeraldApplyTranscript)) {
        throw new Exception(
          "Heraldable must return HeraldApplyTranscripts from ".
          "applyHeraldEffect().");
      }
      $this->transcript->addApplyTranscript($apply_xscript);
    }

    if (!$this->transcript->getDryRun()) {

      $rules = mpull($rules, null, 'getID');
      $applied_ids = array();
      $first_policy = HeraldRepetitionPolicyConfig::toInt(
        HeraldRepetitionPolicyConfig::FIRST);

      // Mark all the rules that have had their effects applied as having been
      // executed for the current object.
      $rule_ids = mpull($xscripts, 'getRuleID');

      foreach ($rule_ids as $rule_id) {
        if (!$rule_id) {
          // Some apply transcripts are purely informational and not associated
          // with a rule, e.g. carryover emails from earlier revisions.
          continue;
        }

        $rule = idx($rules, $rule_id);
        if (!$rule) {
          continue;
        }

        if ($rule->getRepetitionPolicy() == $first_policy) {
          $applied_ids[] = $rule_id;
        }
      }

      if ($applied_ids) {
        $conn_w = id(new HeraldRule())->establishConnection('w');
        $sql = array();
        foreach ($applied_ids as $id) {
          $sql[] = qsprintf(
            $conn_w,
            '(%s, %d)',
            $object->getPHID(),
            $id);
        }
        queryfx(
          $conn_w,
          'INSERT IGNORE INTO %T (phid, ruleID) VALUES %Q',
          HeraldRule::TABLE_RULE_APPLIED,
          implode(', ', $sql));
      }
    }
  }

  public function getTranscript() {
    $this->transcript->save();
    return $this->transcript;
  }

  protected function doesRuleMatch(
    HeraldRule $rule,
    HeraldObjectAdapter $object) {

    $id = $rule->getID();

    if (isset($this->results[$id])) {
      // If we've already evaluated this rule because another rule depends
      // on it, we don't need to reevaluate it.
      return $this->results[$id];
    }

    if (isset($this->stack[$id])) {
      // We've recursed, fail all of the rules on the stack. This happens when
      // there's a dependency cycle with "Rule conditions match for rule ..."
      // conditions.
      foreach ($this->stack as $rule_id => $ignored) {
        $this->results[$rule_id] = false;
      }
      throw new HeraldRecursiveConditionsException();
    }

    $this->stack[$id] = true;

    $all = $rule->getMustMatchAll();

    $conditions = $rule->getConditions();

    $result = null;

    $local_version = id(new HeraldRule())->getConfigVersion();
    if ($rule->getConfigVersion() > $local_version) {
      $reason = "Rule could not be processed, it was created with a newer ".
                "version of Herald.";
      $result = false;
    } else if (!$conditions) {
      $reason = "Rule failed automatically because it has no conditions.";
      $result = false;
    } else if ($rule->hasInvalidOwner()) {
      $reason = "Rule failed automatically because its owner is invalid ".
                "or disabled.";
      $result = false;
    } else {
      foreach ($conditions as $condition) {
        $match = $this->doesConditionMatch($rule, $condition, $object);

        if (!$all && $match) {
          $reason = "Any condition matched.";
          $result = true;
          break;
        }

        if ($all && !$match) {
          $reason = "Not all conditions matched.";
          $result = false;
          break;
        }
      }

      if ($result === null) {
        if ($all) {
          $reason = "All conditions matched.";
          $result = true;
        } else {
          $reason = "No conditions matched.";
          $result = false;
        }
      }
    }

    $rule_transcript = new HeraldRuleTranscript();
    $rule_transcript->setRuleID($rule->getID());
    $rule_transcript->setResult($result);
    $rule_transcript->setReason($reason);
    $rule_transcript->setRuleName($rule->getName());
    $rule_transcript->setRuleOwner($rule->getAuthorPHID());

    $this->transcript->addRuleTranscript($rule_transcript);

    return $result;
  }

  protected function doesConditionMatch(
    HeraldRule $rule,
    HeraldCondition $condition,
    HeraldObjectAdapter $object) {

    $object_value = $this->getConditionObjectValue($condition, $object);
    $test_value   = $condition->getValue();

    $cond = $condition->getFieldCondition();

    $transcript = new HeraldConditionTranscript();
    $transcript->setRuleID($rule->getID());
    $transcript->setConditionID($condition->getID());
    $transcript->setFieldName($condition->getFieldName());
    $transcript->setCondition($cond);
    $transcript->setTestValue($test_value);

    $result = null;
    switch ($cond) {
      case HeraldConditionConfig::CONDITION_CONTAINS:
        // "Contains" can take an array of strings, as in "Any changed
        // filename" for diffs.
        foreach ((array)$object_value as $value) {
          $result = (stripos($value, $test_value) !== false);
          if ($result) {
            break;
          }
        }
        break;
      case HeraldConditionConfig::CONDITION_NOT_CONTAINS:
        $result = (stripos($object_value, $test_value) === false);
        break;
      case HeraldConditionConfig::CONDITION_IS:
        $result = ($object_value == $test_value);
        break;
      case HeraldConditionConfig::CONDITION_IS_NOT:
        $result = ($object_value != $test_value);
        break;
      case HeraldConditionConfig::CONDITION_IS_ME:
        $result = ($object_value == $rule->getAuthorPHID());
        break;
      case HeraldConditionConfig::CONDITION_IS_NOT_ME:
        $result = ($object_value != $rule->getAuthorPHID());
        break;
      case HeraldConditionConfig::CONDITION_IS_ANY:
        $test_value = array_flip($test_value);
        $result = isset($test_value[$object_value]);
        break;
      case HeraldConditionConfig::CONDITION_IS_NOT_ANY:
        $test_value = array_flip($test_value);
        $result = !isset($test_value[$object_value]);
        break;
      case HeraldConditionConfig::CONDITION_INCLUDE_ALL:
        if (!is_array($object_value)) {
          $transcript->setNote('Object produced bad value!');
          $result = false;
        } else {
          $have = array_select_keys(array_flip($object_value),
                                    $test_value);
          $result = (count($have) == count($test_value));
        }
        break;
      case HeraldConditionConfig::CONDITION_INCLUDE_ANY:
        $result = (bool)array_select_keys(array_flip($object_value),
                                          $test_value);
        break;
      case HeraldConditionConfig::CONDITION_INCLUDE_NONE:
        $result = !array_select_keys(array_flip($object_value),
                                     $test_value);
        break;
      case HeraldConditionConfig::CONDITION_EXISTS:
        $result = (bool)$object_value;
        break;
      case HeraldConditionConfig::CONDITION_NOT_EXISTS:
        $result = !$object_value;
        break;
      case HeraldConditionConfig::CONDITION_REGEXP:
        foreach ((array)$object_value as $value) {
          $result = @preg_match($test_value, $value);
          if ($result === false) {
            $transcript->setNote(
              "Regular expression is not valid!");
            break;
          }
          if ($result) {
            break;
          }
        }
        $result = (bool)$result;
        break;
      case HeraldConditionConfig::CONDITION_REGEXP_PAIR:
        // Match a JSON-encoded pair of regular expressions against a
        // dictionary. The first regexp must match the dictionary key, and the
        // second regexp must match the dictionary value. If any key/value pair
        // in the dictionary matches both regexps, the condition is satisfied.
        $regexp_pair = json_decode($test_value, true);
        if (!is_array($regexp_pair)) {
          $result = false;
          $transcript->setNote("Regular expression pair is not valid JSON!");
          break;
        }
        if (count($regexp_pair) != 2) {
          $result = false;
          $transcript->setNote("Regular expression pair is not a pair!");
          break;
        }

        $key_regexp   = array_shift($regexp_pair);
        $value_regexp = array_shift($regexp_pair);

        foreach ((array)$object_value as $key => $value) {
          $key_matches = @preg_match($key_regexp, $key);
          if ($key_matches === false) {
            $result = false;
            $transcript->setNote("First regular expression is invalid!");
            break 2;
          }
          if ($key_matches) {
            $value_matches = @preg_match($value_regexp, $value);
            if ($value_matches === false) {
              $result = false;
              $transcript->setNote("Second regular expression is invalid!");
              break 2;
            }
            if ($value_matches) {
              $result = true;
              break 2;
            }
          }
        }
        $result = false;
        break;
      case HeraldConditionConfig::CONDITION_RULE:
      case HeraldConditionConfig::CONDITION_NOT_RULE:

        $rule = idx($this->rules, $test_value);
        if (!$rule) {
          $transcript->setNote(
            "Condition references a rule which does not exist!");
          $result = false;
        } else {
          $is_not = ($cond == HeraldConditionConfig::CONDITION_NOT_RULE);
          $result = $this->doesRuleMatch($rule, $object);
          if ($is_not) {
            $result = !$result;
          }
        }
        break;
      default:
        throw new HeraldInvalidConditionException(
          "Unknown condition '{$cond}'.");
    }

    $transcript->setResult($result);

    $this->transcript->addConditionTranscript($transcript);

    return $result;
  }

  protected function getConditionObjectValue(
    HeraldCondition $condition,
    HeraldObjectAdapter $object) {

    $field = $condition->getFieldName();

    return $this->getObjectFieldValue($field);
  }

  public function getObjectFieldValue($field) {
    if (isset($this->fieldCache[$field])) {
      return $this->fieldCache[$field];
    }

    $result = null;
    switch ($field) {
      case HeraldFieldConfig::FIELD_RULE:
        $result = null;
        break;
      case HeraldFieldConfig::FIELD_TITLE:
      case HeraldFieldConfig::FIELD_BODY:
      case HeraldFieldConfig::FIELD_DIFF_FILE:
      case HeraldFieldConfig::FIELD_DIFF_CONTENT:
        // TODO: Type should be string.
        $result = $this->object->getHeraldField($field);
        break;
      case HeraldFieldConfig::FIELD_AUTHOR:
      case HeraldFieldConfig::FIELD_REPOSITORY:
      case HeraldFieldConfig::FIELD_MERGE_REQUESTER:
        // TODO: Type should be PHID.
        $result = $this->object->getHeraldField($field);
        break;
      case HeraldFieldConfig::FIELD_TAGS:
      case HeraldFieldConfig::FIELD_REVIEWER:
      case HeraldFieldConfig::FIELD_REVIEWERS:
      case HeraldFieldConfig::FIELD_CC:
      case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVIEWERS:
      case HeraldFieldConfig::FIELD_DIFFERENTIAL_CCS:
        // TODO: Type should be list.
        $result = $this->object->getHeraldField($field);
        break;
      case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE:
      case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE_OWNER:
      case HeraldFieldConfig::FIELD_NEED_AUDIT_FOR_PACKAGE:
        $result = $this->object->getHeraldField($field);
        if (!is_array($result)) {
          throw new HeraldInvalidFieldException(
            "Value of field type {$field} is not an array!");
        }
        break;
      case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVISION:
        // TODO: Type should be boolean I guess.
        $result = $this->object->getHeraldField($field);
        break;
      default:
        throw new HeraldInvalidConditionException(
          "Unknown field type '{$field}'!");
    }

    $this->fieldCache[$field] = $result;
    return $result;
  }

  protected function getRuleEffects(
    HeraldRule $rule,
    HeraldObjectAdapter $object) {

    $effects = array();
    foreach ($rule->getActions() as $action) {
      $effect = new HeraldEffect();
      $effect->setObjectPHID($object->getPHID());
      $effect->setAction($action->getAction());
      $effect->setTarget($action->getTarget());

      $effect->setRuleID($rule->getID());

      $name = $rule->getName();
      $id   = $rule->getID();
      $effect->setReason(
        'Conditions were met for Herald rule "'.$name.'" (#'.$id.').');

      $effects[] = $effect;
    }
    return $effects;
  }

}
