<?php

final class PhutilICSWriter extends Phobject {

  public function writeICSDocument(PhutilCalendarRootNode $node) {
    $out = array();

    foreach ($node->getChildren() as $child) {
      $out[] = $this->writeNode($child);
    }

    return implode('', $out);
  }

  private function writeNode(PhutilCalendarNode $node) {
    if (!$this->getICSNodeType($node)) {
      return null;
    }

    $out = array();

    $out[] = $this->writeBeginNode($node);
    $out[] = $this->writeNodeProperties($node);

    if ($node instanceof PhutilCalendarContainerNode) {
      foreach ($node->getChildren() as $child) {
        $out[] = $this->writeNode($child);
      }
    }

    $out[] = $this->writeEndNode($node);

    return implode('', $out);
  }

  private function writeBeginNode(PhutilCalendarNode $node) {
    $type = $this->getICSNodeType($node);
    return $this->wrapICSLine("BEGIN:{$type}");
  }

  private function writeEndNode(PhutilCalendarNode $node) {
    $type = $this->getICSNodeType($node);
    return $this->wrapICSLine("END:{$type}");
  }

  private function writeNodeProperties(PhutilCalendarNode $node) {
    $properties = $this->getNodeProperties($node);

    $out = array();
    foreach ($properties as $property) {
      $propname = $property['name'];
      $propvalue = $property['value'];

      $propline = array();
      $propline[] = $propname;

      foreach ($property['parameters'] as $parameter) {
        $paramname = $parameter['name'];
        $paramvalue = $parameter['value'];
        $propline[] = ";{$paramname}={$paramvalue}";
      }

      $propline[] = ":{$propvalue}";
      $propline = implode('', $propline);

      $out[] = $this->wrapICSLine($propline);
    }

    return implode('', $out);
  }

  private function getICSNodeType(PhutilCalendarNode $node) {
    switch ($node->getNodeType()) {
      case PhutilCalendarDocumentNode::NODETYPE:
        return 'VCALENDAR';
      case PhutilCalendarEventNode::NODETYPE:
        return 'VEVENT';
      default:
        return null;
    }
  }

  private function wrapICSLine($line) {
    $out = array();
    $buf = '';

    // NOTE: The line may contain sequences of combining characters which are
    // more than 80 bytes in length. If it does, we'll split them in the
    // middle of the sequence. This is okay and generally anticipated by
    // RFC5545, which even allows implementations to split multibyte
    // characters. The sequence will be stitched back together properly by
    // whatever is parsing things.

    foreach (phutil_utf8v($line) as $character) {
      // If adding this character would bring the line over 75 bytes, start
      // a new line.
      if (strlen($buf) + strlen($character) > 75) {
        $out[] = $buf."\r\n";
        $buf = ' ';
      }

      $buf .= $character;
    }

    $out[] = $buf."\r\n";

    return implode('', $out);
  }

  private function getNodeProperties(PhutilCalendarNode $node) {
    switch ($node->getNodeType()) {
      case PhutilCalendarDocumentNode::NODETYPE:
        return $this->getDocumentNodeProperties($node);
      case PhutilCalendarEventNode::NODETYPE:
        return $this->getEventNodeProperties($node);
      default:
        return array();
    }
  }

  private function getDocumentNodeProperties(
    PhutilCalendarDocumentNode $event) {
    $properties = array();

    $properties[] = $this->newTextProperty(
      'VERSION',
      '2.0');

    $properties[] = $this->newTextProperty(
      'PRODID',
      '-//Phacility//Phabricator//EN');

    return $properties;
  }

  private function getEventNodeProperties(PhutilCalendarEventNode $event) {
    $properties = array();

    $uid = $event->getUID();
    if (!strlen($uid)) {
      throw new Exception(
        pht(
          'Unable to write ICS document: event has no UID, but each event '.
          'MUST have a UID.'));
    }
    $properties[] = $this->newTextProperty(
      'UID',
      $uid);

    $created = $event->getCreatedDateTime();
    if ($created) {
      $properties[] = $this->newDateTimeProperty(
        'CREATED',
        $event->getCreatedDateTime());
    }

    $dtstamp = $event->getModifiedDateTime();
    if (!$dtstamp) {
      throw new Exception(
        pht(
          'Unable to write ICS document: event has no modified time, but '.
          'each event MUST have a modified time.'));
    }
    $properties[] = $this->newDateTimeProperty(
      'DTSTAMP',
      $dtstamp);

    $dtstart = $event->getStartDateTime();
    if ($dtstart) {
      $properties[] = $this->newDateTimeProperty(
        'DTSTART',
        $dtstart);
    }

    $dtend = $event->getEndDateTime();
    if ($dtend) {
      $properties[] = $this->newDateTimeProperty(
        'DTEND',
        $event->getEndDateTime());
    }

    $name = $event->getName();
    if (strlen($name)) {
      $properties[] = $this->newTextProperty(
        'SUMMARY',
        $name);
    }

    $description = $event->getDescription();
    if (strlen($description)) {
      $properties[] = $this->newTextProperty(
        'DESCRIPTION',
        $description);
    }

    $organizer = $event->getOrganizer();
    if ($organizer) {
      $properties[] = $this->newUserProperty(
        'ORGANIZER',
        $organizer);
    }

    $attendees = $event->getAttendees();
    if ($attendees) {
      foreach ($attendees as $attendee) {
        $properties[] = $this->newUserProperty(
          'ATTENDEE',
          $attendee);
      }
    }

    $rrule = $event->getRecurrenceRule();
    if ($rrule) {
      $properties[] = $this->newRRULEProperty(
        'RRULE',
        $rrule);
    }

    $recurrence_id = $event->getRecurrenceID();
    if ($recurrence_id) {
      $properties[] = $this->newTextProperty(
        'RECURRENCE-ID',
        $recurrence_id);
    }

    $exdates = $event->getRecurrenceExceptions();
    if ($exdates) {
      $properties[] = $this->newDateTimesProperty(
        'EXDATE',
        $exdates);
    }

    $rdates = $event->getRecurrenceDates();
    if ($rdates) {
      $properties[] = $this->newDateTimesProperty(
        'RDATE',
        $rdates);
    }

    return $properties;
  }

  private function newTextProperty(
    $name,
    $value,
    array $parameters = array()) {

    $map = array(
      '\\' => '\\\\',
      ',' => '\\,',
      "\n" => '\\n',
    );

    $value = (array)$value;
    foreach ($value as $k => $v) {
      $v = str_replace(array_keys($map), array_values($map), $v);
      $value[$k] = $v;
    }

    $value = implode(',', $value);

    return $this->newProperty($name, $value, $parameters);
  }

  private function newDateTimeProperty(
    $name,
    PhutilCalendarDateTime $value,
    array $parameters = array()) {

    return $this->newDateTimesProperty($name, array($value), $parameters);
  }

  private function newDateTimesProperty(
    $name,
    array $values,
    array $parameters = array()) {
    assert_instances_of($values, 'PhutilCalendarDateTime');

    if (head($values)->getIsAllDay()) {
      $parameters[] = array(
        'name' => 'VALUE',
        'values' => array(
          'DATE',
        ),
      );
    }

    $datetimes = array();
    foreach ($values as $value) {
      $datetimes[] = $value->getISO8601();
    }
    $datetimes = implode(';', $datetimes);

    return $this->newProperty($name, $datetimes, $parameters);
  }

  private function newUserProperty(
    $name,
    PhutilCalendarUserNode $value,
    array $parameters = array()) {

    $parameters[] = array(
      'name' => 'CN',
      'values' => array(
        $value->getName(),
      ),
    );

    $partstat = null;
    switch ($value->getStatus()) {
      case PhutilCalendarUserNode::STATUS_INVITED:
        $partstat = 'NEEDS-ACTION';
        break;
      case PhutilCalendarUserNode::STATUS_ACCEPTED:
        $partstat = 'ACCEPTED';
        break;
      case PhutilCalendarUserNode::STATUS_DECLINED:
        $partstat = 'DECLINED';
        break;
    }

    if ($partstat !== null) {
      $parameters[] = array(
        'name' => 'PARTSTAT',
        'values' => array(
          $partstat,
        ),
      );
    }

    // TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it
    // isn't clear if these are important to external programs or not.

    return $this->newProperty($name, $value->getURI(), $parameters);
  }

  private function newRRULEProperty(
    $name,
    PhutilCalendarRecurrenceRule $rule,
    array $parameters = array()) {

    $value = $rule->toRRULE();
    return $this->newProperty($name, $value, $parameters);
  }

  private function newProperty(
    $name,
    $value,
    array $parameters = array()) {

    $map = array(
      '^' => '^^',
      "\n" => '^n',
      '"' => "^'",
    );

    $writable_params = array();
    foreach ($parameters as $k => $parameter) {
      $value_list = array();
      foreach ($parameter['values'] as $v) {
        $v = str_replace(array_keys($map), array_values($map), $v);

        // If the parameter value isn't a very simple one, quote it.

        // RFC5545 says that we MUST quote it if it has a colon, a semicolon,
        // or a comma, and that we MUST quote it if it's a URI.
        if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) {
          $v = '"'.$v.'"';
        }

        $value_list[] = $v;
      }

      $writable_params[] = array(
        'name' => $parameter['name'],
        'value' => implode(',', $value_list),
      );
    }

    return array(
      'name' => $name,
      'value' => $value,
      'parameters' => $writable_params,
    );
  }

}
