source = str_replace(array("\r\n","\r"), "\n", $source);
      $this->size = strlen($this->source);
   }
   function remaining () {
      return $this->size - $this->pos;
   }
   function consume ($count = 1) {
      $val = '';
      for($i = 0; $i < $count; $i++) {
         if ($this->eof()) throw new Exception('End of file reached during read.', 801);
         $val = $val . $this->source[$this->pos];
         $this->pos++;
      }
      return $val;
   }
   function consumeUntil ($char, $inclusive = false, $stopAtEOF = false) {
      $skipped = '';
      while($this->source[$this->pos] !== $char) {
         if ($this->eof()) {
            if ($stopAtEOF) return $skipped;
            throw new Exception('End of file reached during read.', 801);
         }
         $skipped = $skipped . $this->source[$this->pos];
         $this->pos++;
      }
      if ($inclusive) {
         $skipped = $skipped . $char;
         $this->pos++;
      }
      return $skipped;
   }
   function consumeWhile ($char) {
      $skipped = '';
      while($this->source[$this->pos] === $char) {
         if ($this->eof()) throw new Exception('End of file reached during read.', 801);
         $skipped = $skipped . $this->source[$this->pos];
         $this->pos++;
      }
      return $skipped;
   }
   function eof () {
      return $this->pos >= $this->size;
   }
   function getChar () {
      return $this->source[$this->pos];
   }
   function lookAhead ($steps) {
      if (($this->pos + $steps) > $this->size) {
         throw new Exception('End of file reached during read.', 801);
      }
      $result = "";
      $i = $this->pos;
      for (; $i < ($this->pos + $steps); $i++) {
         $result = $result . $this->source[$i];
      }
      return $result;
   }
   function lookFoward ($pos) {
      if ($pos + $this->pos > $this->size) {
         throw new Exception('End of file reached during read.', 801);
      }
      return $this->source[$pos + $this->pos];
   }
   function lookBack ($steps) {
      if (($this->pos - $steps) < 0) {
         throw new Exception('End of file reached during read.', 801);
      }
      $result = "";
      $i = $this->pos;
      for (; $i > ($this->pos - $steps); $i--) {
         $result = $result . $this->source[$i];
      }
      return $result;
   }
}
class Parser {
   private $reader;
   private $line = 1;
   function __construct ($reader) {
      $this->reader =  $reader;
   }
   function skipBlanklines () {
      while (in_array($this->reader->getChar(), array(" ", "\n"))) {
         $this->reader->consumeWhile(' ');
         if ($this->reader->getChar() === "\n") {
            $this->line++;
            $this->reader->consume();
         }
      }
   }
   function parse () {
      $numbers = array();
      $questions = array();
      try {
         while (!$this->reader->eof()) {
            $this->skipBlanklines();
            if($this->reader->eof()) break;
            $char = $this->reader->getChar();
            if ($char === '#') {
               // skip comment
               $this->reader->consumeUntil("\n", true);
               $this->line++;
               continue;
            }
            $char = $this->reader->consume();
            if ($char === ':') {
               $foward = $this->reader->getChar();
               if($foward !== ':') {
                  $actual = $char.$foward;
                  throw new Exception("Error na linha $this->line. ${actual} não é uma expressão válida, talvez você quisesse '::' (quebra de página )?", 800);
               }
               $this->reader->consumeUntil("\n", true);
               $this->line++;
               $pagebreak = new stdClass;
               $pagebreak->type = 'PAGEBREAK';
               $questions[] = $pagebreak;
               continue;
            }
            if (is_numeric($char)) {
               $foward = $this->reader->getChar();
               if (is_numeric($foward)) {
                  // two digits
                  $char = $char . $foward;
                  $this->reader->consume();
                  $foward = $this->reader->getChar();
               }
               if ($foward !== '.') {
                  throw new Exception("Error na linha $this->line. O número da questão deve ser seguido de um .(ponto).", 800);
               }
               if (in_array($char, $numbers)) {
                  throw new Exception('Error na linha '.($this->line).'. O número '.$char.' já foi utilizado anteriormente.', 802);
               }
               $numbers[] = $char;
               // read '.'
               $this->reader->consume();
               $questions[] = $this->parseQuestion($char);
            } else {
               throw new Exception('Error na linha '.($this->line).'. Você deve informar uma questão nessa linha', 800);
            }
         }
      } catch (Exception $e) {
         $message = $e->getMessage();
         // echo $e->getTraceAsString();
         throw new Exception ("Erro de processamento: $message Total de linhas processadas: $this->line", 800);
      }
      if (count($questions) == 0) {
         throw new Exception('Error na linha '.($this->line).'. Questionário deve ter ao menos uma questão.', 806);
      }
      return $questions;
   }
   function parseQuestion ($number) {
      $question = new stdClass();
      $question->number = 'q'.$number;
      // params
      $this->reader->consumeWhile(' ');
      if ($this->reader->getChar() !== '[') {
         throw new Exception('Error na linha '.($this->line).'. Toda questão deve ter seus parâmetros (M, D, S, I e *) definidos entre [ ].', 800);
      }
      $this->reader->consume();
      $params = $this->reader->consumeUntil("]");
      $params = str_replace(' ', '', $params);
      $validTypes = ['M','D','S', 'I'];
      if (strlen($params) === 0) {
         throw new Exception('Error na linha '.($this->line).'. Questões devem ter um parâmetro de tipo: M, D, I ou S.', 803);
      }
      $found = false;
      foreach ($validTypes as $type) {
         if (strpos($params, $type) !== FALSE) {
            if ($found) {
               throw new Exception('Error na linha '.($this->line).'. Questões devem ter apenas um parâmetro de tipo: M, D, I ou S.', 803);
            }
            $question->type = $type;
            $found = true;
         }
      }
      if (!$found) {
         throw new Exception('Error na linha '.($this->line).'. Questões devem ter um parâmetro de tipo: M, D, I ou S.', 803);
      }
      $params = str_replace($validTypes, '', $params);
      if(strlen($params) > 0) {
         if (strpos($params, '*') !== FALSE) {
            $question->required = true;
            $params = str_replace('*', '', $params);
         } else {
            throw new Exception('Error na linha '.($this->line).'. Parâmetro desconhecido:'.$params.'. Os parâmetros válidos são: M, D, S, I e *.', 800);
         }
      }
      if(strlen($params) > 0) {
         throw new Exception('Error na linha '.($this->line).'. Parâmetro desconhecido:'.$params.'. Os parâmetros válidos são: M, D, S, I e *.', 800);
      }
      $this->reader->consume();
      $this->reader->consumeWhile(' ');
      $question->text = trim($this->reader->consumeUntil("\n", false, true));
      if (strlen($question->text) === 0) {
         throw new Exception('Error na linha '.($this->line).'. Questões devem ter um texto associado!', 800);
      }
      if (!$this->reader->eof()) {
         $this->reader->consume();
         $this->line++;
      }
      // echo $question->type;
      // echo '
';
      if (in_array($question->type, array('M','S'))) {
         $this->parseChoices($question);
         if (count($question->choices) < 2) {
            throw new Exception('Error na linha '.($this->line).'. A questão '.$question->number.' é de múltipla escolha e deve ter pelo menos duas alternativas.', 805);
         }
      }
      return $question;
   }
   function parseChoices ($question) {
      $question->choices = array();
      $letters = array();
      while (!$this->reader->eof()) {
         $this->skipBlanklines();
         if($this->reader->eof()){
            // blank lines at the end of file
            continue;
         }
         $char = $this->reader->getChar();
         $isPoint = $this->reader->lookFoward(1);
         if ($char === '#') {
            $this->reader->consumeUntil("\n", true);
            $this->line++;
            continue;
         } else if ($char === ':' || is_numeric($char)) {
            break;
         } else if ($isPoint !== '.') {
            // must be CONDICAO
            $cond = "CONDICAO:";
            $actual = $this->reader->lookAhead(strlen($cond));
            if(strcmp($cond, $actual) !== 0) {
               throw new Exception("Error na linha ".($this->line).". A expressão ".$actual." é desconhecida, o único valor válido nesse contexto seria a expressão 'CONDICAO:'.", 800);
            }
            // TODO parse conditionals
            $this->reader->consumeUntil("\n", true);
            $this->line++;
            break;
         }
         // echo 'Choice:'.$char;
         // echo '
';
         if (in_array($char, $letters)) {
            throw new Exception('Error na linha '.($this->line).'. A letra '.$char.' já foi utilizada anteriormente nesta mesma questão.', 804);
         } else if (!preg_match('/[A-Z]/', $char)) {
            throw new Exception('Error na linha '.($this->line).'. As alternativas das questões devem ser letras maiúsculas A-Z.', 800);
         } else if ($isPoint !== '.') {
            throw new Exception('Error na linha '.($this->line).'. A letra da alternativa deve ser seguida de um .(ponto)!', 800);
         }
         $choice = new stdClass();
         $choice->value = $char;
         $this->reader->consumeUntil(".", true);
         $this->reader->consumeWhile(' ');
         // text can be last line of valid text
         $choice->text = trim($this->reader->consumeUntil("\n", false, true));
         if (strlen($choice->text) === 0) {
            throw new Exception('Error na linha '.($this->line).'. Alternativas devem ter um texto associado!', 800);
         }
         if(!$this->reader->eof()) {
            $this->reader->consume();
            $this->line++;
         }
         $question->choices[] = $choice;
      }
   }
}