<?php

final class CelerityResourceMapGenerator extends Phobject {

  private $debug = false;
  private $resources;

  private $nameMap     = array();
  private $symbolMap   = array();
  private $requiresMap = array();
  private $packageMap  = array();

  public function __construct(CelerityPhysicalResources $resources) {
    $this->resources = $resources;
  }

  public function getNameMap() {
    return $this->nameMap;
  }

  public function getSymbolMap() {
    return $this->symbolMap;
  }

  public function getRequiresMap() {
    return $this->requiresMap;
  }

  public function getPackageMap() {
    return $this->packageMap;
  }

  public function setDebug($debug) {
    $this->debug = $debug;
    return $this;
  }

  protected function log($message) {
    if ($this->debug) {
      $console = PhutilConsole::getConsole();
      $console->writeErr("%s\n", $message);
    }
  }

  public function generate() {
    $binary_map = $this->rebuildBinaryResources($this->resources);

    $this->log(pht('Found %d binary resources.', count($binary_map)));

    $xformer = id(new CelerityResourceTransformer())
      ->setMinify(false)
      ->setRawURIMap(ipull($binary_map, 'uri'));

    $text_map = $this->rebuildTextResources($this->resources, $xformer);

    $this->log(pht('Found %d text resources.', count($text_map)));

    $resource_graph = array();
    $requires_map = array();
    $symbol_map = array();
    foreach ($text_map as $name => $info) {
      if (isset($info['provides'])) {
        $symbol_map[$info['provides']] = $info['hash'];

        // We only need to check for cycles and add this to the requires map
        // if it actually requires anything.
        if (!empty($info['requires'])) {
          $resource_graph[$info['provides']] = $info['requires'];
          $requires_map[$info['hash']] = $info['requires'];
        }
      }
    }

    $this->detectGraphCycles($resource_graph);
    $name_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash');
    $hash_map = array_flip($name_map);

    $package_map = $this->rebuildPackages(
      $this->resources,
      $symbol_map,
      $hash_map);

    $this->log(pht('Found %d packages.', count($package_map)));

    $component_map = array();
    foreach ($package_map as $package_name => $package_info) {
      foreach ($package_info['symbols'] as $symbol) {
        $component_map[$symbol] = $package_name;
      }
    }

    $name_map = $this->mergeNameMaps(
      array(
        array(pht('Binary'), ipull($binary_map, 'hash')),
        array(pht('Text'), ipull($text_map, 'hash')),
        array(pht('Package'), ipull($package_map, 'hash')),
      ));
    $package_map = ipull($package_map, 'symbols');

    ksort($name_map, SORT_STRING);
    ksort($symbol_map, SORT_STRING);
    ksort($requires_map, SORT_STRING);
    ksort($package_map, SORT_STRING);

    $this->nameMap     = $name_map;
    $this->symbolMap   = $symbol_map;
    $this->requiresMap = $requires_map;
    $this->packageMap  = $package_map;

    return $this;
  }

  public function write() {
    $map_content = $this->formatMapContent(array(
      'names'    => $this->getNameMap(),
      'symbols'  => $this->getSymbolMap(),
      'requires' => $this->getRequiresMap(),
      'packages' => $this->getPackageMap(),
    ));

    $map_path = $this->resources->getPathToMap();
    $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path)));
    Filesystem::writeFile($map_path, $map_content);

    return $this;
  }

  private function formatMapContent(array $data) {
    $content = phutil_var_export($data);
    $generated = '@'.'generated';

    return <<<EOFILE
<?php

/**
 * This file is automatically generated. Use 'bin/celerity map' to rebuild it.
 *
 * {$generated}
 */
return {$content};

EOFILE;
  }

  /**
   * Find binary resources (like PNG and SWF) and return information about
   * them.
   *
   * @param CelerityPhysicalResources Resource map to find binary resources for.
   * @return map<string, map<string, string>> Resource information map.
   */
  private function rebuildBinaryResources(
    CelerityPhysicalResources $resources) {

    $binary_map = $resources->findBinaryResources();
    $result_map = array();

    foreach ($binary_map as $name => $data_hash) {
      $hash = $this->newResourceHash($data_hash.$name);

      $result_map[$name] = array(
        'hash' => $hash,
        'uri'  => $resources->getResourceURI($hash, $name),
      );
    }

    return $result_map;
  }

  /**
   * Find text resources (like JS and CSS) and return information about them.
   *
   * @param CelerityPhysicalResources Resource map to find text resources for.
   * @param CelerityResourceTransformer Configured resource transformer.
   * @return map<string, map<string, string>> Resource information map.
   */
  private function rebuildTextResources(
    CelerityPhysicalResources $resources,
    CelerityResourceTransformer $xformer) {

    $text_map = $resources->findTextResources();
    $result_map = array();

    foreach ($text_map as $name => $data_hash) {
      $raw_data = $resources->getResourceData($name);
      $xformed_data = $xformer->transformResource($name, $raw_data);

      $data_hash = $this->newResourceHash($xformed_data);
      $hash = $this->newResourceHash($data_hash.$name);

      list($provides, $requires) = $this->getProvidesAndRequires(
        $name,
        $raw_data);

      $result_map[$name] = array(
        'hash' => $hash,
      );

      if ($provides !== null) {
        $result_map[$name] += array(
          'provides' => $provides,
          'requires' => $requires,
        );
      }
    }

    return $result_map;
  }

  /**
   * Parse the `@provides` and `@requires` symbols out of a text resource, like
   * JS or CSS.
   *
   * @param string Resource name.
   * @param string Resource data.
   * @return pair<string|null, list<string>|null> The `@provides` symbol and
   *    the list of `@requires` symbols. If the resource is not part of the
   *    dependency graph, both are null.
   */
  private function getProvidesAndRequires($name, $data) {
    $parser = new PhutilDocblockParser();

    $matches = array();
    $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches);
    if (!$ok) {
      throw new Exception(
        pht(
          'Resource "%s" does not have a header doc comment. Encode '.
          'dependency data in a header docblock.',
          $name));
    }

    list($description, $metadata) = $parser->parse($matches[0]);

    $provides = $this->parseResourceSymbolList(idx($metadata, 'provides'));
    $requires = $this->parseResourceSymbolList(idx($metadata, 'requires'));
    if (!$provides) {
      // Tests and documentation-only JS is permitted to @provide no targets.
      return array(null, null);
    }

    if (count($provides) > 1) {
      throw new Exception(
        pht(
          'Resource "%s" must %s at most one Celerity target.',
          $name,
          '@provide'));
    }

    return array(head($provides), $requires);
  }

  /**
   * Check for dependency cycles in the resource graph. Raises an exception if
   * a cycle is detected.
   *
   * @param map<string, list<string>> Map of `@provides` symbols to their
   *                                  `@requires` symbols.
   * @return void
   */
  private function detectGraphCycles(array $nodes) {
    $graph = id(new CelerityResourceGraph())
      ->addNodes($nodes)
      ->setResourceGraph($nodes)
      ->loadGraph();

    foreach ($nodes as $provides => $requires) {
      $cycle = $graph->detectCycles($provides);
      if ($cycle) {
        throw new Exception(
          pht(
            'Cycle detected in resource graph: %s',
            implode(' > ', $cycle)));
      }
    }
  }

  /**
   * Build package specifications for a given resource source.
   *
   * @param CelerityPhysicalResources Resource source to rebuild.
   * @param map<string, string> Map of `@provides` to hashes.
   * @param map<string, string> Map of hashes to resource names.
   * @return map<string, map<string, string>> Package information maps.
   */
  private function rebuildPackages(
    CelerityPhysicalResources $resources,
    array $symbol_map,
    array $reverse_map) {

    $package_map = array();

    $package_spec = $resources->getResourcePackages();
    foreach ($package_spec as $package_name => $package_symbols) {
      $type = null;
      $hashes = array();
      foreach ($package_symbols as $symbol) {
        $symbol_hash = idx($symbol_map, $symbol);
        if ($symbol_hash === null) {
          throw new Exception(
            pht(
              'Package specification for "%s" includes "%s", but that symbol '.
              'is not %s by any resource.',
              $package_name,
              $symbol,
              '@provided'));
        }

        $resource_name = $reverse_map[$symbol_hash];
        $resource_type = $resources->getResourceType($resource_name);
        if ($type === null) {
          $type = $resource_type;
        } else if ($type !== $resource_type) {
          throw new Exception(
            pht(
              'Package specification for "%s" includes resources of multiple '.
              'types (%s, %s). Each package may only contain one type of '.
              'resource.',
              $package_name,
              $type,
              $resource_type));
        }

        $hashes[] = $symbol.':'.$symbol_hash;
      }

      $hash = $this->newResourceHash(implode("\n", $hashes));
      $package_map[$package_name] = array(
        'hash' => $hash,
        'symbols' => $package_symbols,
      );
    }

    return $package_map;
  }

  private function mergeNameMaps(array $maps) {
    $result = array();
    $origin = array();

    foreach ($maps as $map) {
      list($map_name, $data) = $map;
      foreach ($data as $name => $hash) {
        if (empty($result[$name])) {
          $result[$name] = $hash;
          $origin[$name] = $map_name;
        } else {
          $old = $origin[$name];
          $new = $map_name;
          throw new Exception(
            pht(
              'Resource source defines two resources with the same name, '.
              '"%s". One is defined in the "%s" map; the other in the "%s" '.
              'map. Each resource must have a unique name.',
              $name,
              $old,
              $new));
        }
      }
    }
    return $result;
  }

  private function parseResourceSymbolList($list) {
    if (!$list) {
      return array();
    }

    // This is valid:
    //
    //   @requires x y
    //
    // But so is this:
    //
    //   @requires x
    //   @requires y
    //
    // Accept either form and produce a list of symbols.

    $list = (array)$list;

    // We can get `true` values if there was a bare `@requires` in the input.
    foreach ($list as $key => $item) {
      if ($item === true) {
        unset($list[$key]);
      }
    }

    $list = implode(' ', $list);
    $list = trim($list);
    $list = preg_split('/\s+/', $list);
    $list = array_filter($list);

    return $list;
  }

  private function newResourceHash($data) {
    // This HMAC key is a static, hard-coded value because we don't want the
    // hashes in the map to depend on database state: when two different
    // developers regenerate the map, they should end up with the same output.

    $hash = PhabricatorHash::digestHMACSHA256($data, 'celerity-resource-data');

    return substr($hash, 0, 8);
  }

}
