<?php

abstract class AphrontBaseMySQLDatabaseConnection
  extends AphrontDatabaseConnection {

  private $configuration;
  private $connection;
  private $connectionPool = array();
  private $lastResult;

  private $nextError;

  abstract protected function connect();
  abstract protected function rawQuery($raw_query);
  abstract protected function rawQueries(array $raw_queries);
  abstract protected function fetchAssoc($result);
  abstract protected function getErrorCode($connection);
  abstract protected function getErrorDescription($connection);
  abstract protected function closeConnection();
  abstract protected function freeResult($result);

  public function __construct(array $configuration) {
    $this->configuration = $configuration;
  }

  public function __clone() {
    $this->establishConnection();
  }

  public function openConnection() {
    $this->requireConnection();
  }

  public function close() {
    if ($this->lastResult) {
      $this->lastResult = null;
    }
    if ($this->connection) {
      $this->closeConnection();
      $this->connection = null;
    }
  }

  public function escapeColumnName($name) {
    return '`'.str_replace('`', '``', $name).'`';
  }


  public function escapeMultilineComment($comment) {
    // These can either terminate a comment, confuse the hell out of the parser,
    // make MySQL execute the comment as a query, or, in the case of semicolon,
    // are quasi-dangerous because the semicolon could turn a broken query into
    // a working query plus an ignored query.

    static $map = array(
      '--'  => '(DOUBLEDASH)',
      '*/'  => '(STARSLASH)',
      '//'  => '(SLASHSLASH)',
      '#'   => '(HASH)',
      '!'   => '(BANG)',
      ';'   => '(SEMICOLON)',
    );

    $comment = str_replace(
      array_keys($map),
      array_values($map),
      $comment);

    // For good measure, kill anything else that isn't a nice printable
    // character.
    $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment);

    return '/* '.$comment.' */';
  }

  public function escapeStringForLikeClause($value) {
    $value = addcslashes($value, '\%_');
    $value = $this->escapeUTF8String($value);
    return $value;
  }

  protected function getConfiguration($key, $default = null) {
    return idx($this->configuration, $key, $default);
  }

  private function establishConnection() {
    $host = $this->getConfiguration('host');
    $database = $this->getConfiguration('database');

    $profiler = PhutilServiceProfiler::getInstance();
    $call_id = $profiler->beginServiceCall(
      array(
        'type'      => 'connect',
        'host'      => $host,
        'database'  => $database,
      ));

    $retries = max(1, $this->getConfiguration('retries', 3));
    while ($retries--) {
      try {
        $conn = $this->connect();
        $profiler->endServiceCall($call_id, array());
        break;
      } catch (AphrontQueryException $ex) {
        if ($retries && $ex->getCode() == 2003) {
          $class = get_class($ex);
          $message = $ex->getMessage();
          phlog(pht('Retrying (%d) after %s: %s', $retries, $class, $message));
        } else {
          $profiler->endServiceCall($call_id, array());
          throw $ex;
        }
      }
    }

    $this->connection = $conn;
  }

  protected function requireConnection() {
    if (!$this->connection) {
      if ($this->connectionPool) {
        $this->connection = array_pop($this->connectionPool);
      } else {
        $this->establishConnection();
      }
    }
    return $this->connection;
  }

  protected function beginAsyncConnection() {
    $connection = $this->requireConnection();
    $this->connection = null;
    return $connection;
  }

  protected function endAsyncConnection($connection) {
    if ($this->connection) {
      $this->connectionPool[] = $this->connection;
    }
    $this->connection = $connection;
  }

  public function selectAllResults() {
    $result = array();
    $res = $this->lastResult;
    if ($res == null) {
      throw new Exception(pht('No query result to fetch from!'));
    }
    while (($row = $this->fetchAssoc($res))) {
      $result[] = $row;
    }
    return $result;
  }

  public function executeRawQuery($raw_query) {
    $this->lastResult = null;
    $retries = max(1, $this->getConfiguration('retries', 3));
    while ($retries--) {
      try {
        $this->requireConnection();
        $is_write = $this->checkWrite($raw_query);

        $profiler = PhutilServiceProfiler::getInstance();
        $call_id = $profiler->beginServiceCall(
          array(
            'type'    => 'query',
            'config'  => $this->configuration,
            'query'   => $raw_query,
            'write'   => $is_write,
          ));

        $result = $this->rawQuery($raw_query);

        $profiler->endServiceCall($call_id, array());

        if ($this->nextError) {
          $result = null;
        }

        if ($result) {
          $this->lastResult = $result;
          break;
        }

        $this->throwQueryException($this->connection);
      } catch (AphrontConnectionLostQueryException $ex) {
        $can_retry = ($retries > 0);

        if ($this->isInsideTransaction()) {
          // Zero out the transaction state to prevent a second exception
          // ("program exited with open transaction") from being thrown, since
          // we're about to throw a more relevant/useful one instead.
          $state = $this->getTransactionState();
          while ($state->getDepth()) {
            $state->decreaseDepth();
          }

          $can_retry = false;
        }

        if ($this->isHoldingAnyLock()) {
          $this->forgetAllLocks();
          $can_retry = false;
        }

        $this->close();

        if (!$can_retry) {
          throw $ex;
        }
      }
    }
  }

  public function executeRawQueries(array $raw_queries) {
    if (!$raw_queries) {
      return array();
    }

    $is_write = false;
    foreach ($raw_queries as $key => $raw_query) {
      $is_write = $is_write || $this->checkWrite($raw_query);
      $raw_queries[$key] = rtrim($raw_query, "\r\n\t ;");
    }

    $profiler = PhutilServiceProfiler::getInstance();
    $call_id = $profiler->beginServiceCall(
      array(
        'type'    => 'multi-query',
        'config'  => $this->configuration,
        'queries' => $raw_queries,
        'write'   => $is_write,
      ));

    $results = $this->rawQueries($raw_queries);

    $profiler->endServiceCall($call_id, array());

    return $results;
  }

  protected function processResult($result) {
    if (!$result) {
      try {
        $this->throwQueryException($this->requireConnection());
      } catch (Exception $ex) {
        return $ex;
      }
    } else if (is_bool($result)) {
      return $this->getAffectedRows();
    }
    $rows = array();
    while (($row = $this->fetchAssoc($result))) {
      $rows[] = $row;
    }
    $this->freeResult($result);
    return $rows;
  }

  protected function checkWrite($raw_query) {
    // NOTE: The opening "(" allows queries in the form of:
    //
    //   (SELECT ...) UNION (SELECT ...)
    $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query);
    if ($is_write) {
      if ($this->getReadOnly()) {
        throw new Exception(
          pht(
            'Attempting to issue a write query on a read-only '.
            'connection (to database "%s")!',
            $this->getConfiguration('database')));
      }
      AphrontWriteGuard::willWrite();
      return true;
    }

    return false;
  }

  protected function throwQueryException($connection) {
    if ($this->nextError) {
      $errno = $this->nextError;
      $error = pht('Simulated error.');
      $this->nextError = null;
    } else {
      $errno = $this->getErrorCode($connection);
      $error = $this->getErrorDescription($connection);
    }
    $this->throwQueryCodeException($errno, $error);
  }

  private function throwCommonException($errno, $error) {
    $message = pht('#%d: %s', $errno, $error);

    switch ($errno) {
      case 2013: // Connection Dropped
        throw new AphrontConnectionLostQueryException($message);
      case 2006: // Gone Away
        $more = pht(
          "This error may occur if your MySQL '%s' or '%s' ".
          "configuration values are set too low.",
          'wait_timeout',
          'max_allowed_packet');
        throw new AphrontConnectionLostQueryException("{$message}\n\n{$more}");
      case 1213: // Deadlock
        throw new AphrontDeadlockQueryException($message);
      case 1205: // Lock wait timeout exceeded
        throw new AphrontLockTimeoutQueryException($message);
      case 1062: // Duplicate Key
        // NOTE: In some versions of MySQL we get a key name back here, but
        // older versions just give us a key index ("key 2") so it's not
        // portable to parse the key out of the error and attach it to the
        // exception.
        throw new AphrontDuplicateKeyQueryException($message);
      case 1044: // Access denied to database
      case 1142: // Access denied to table
      case 1143: // Access denied to column
      case 1227: // Access denied (e.g., no SUPER for SHOW SLAVE STATUS).
        throw new AphrontAccessDeniedQueryException($message);
      case 1045: // Access denied (auth)
        throw new AphrontInvalidCredentialsQueryException($message);
      case 1146: // No such table
      case 1049: // No such database
      case 1054: // Unknown column "..." in field list
        throw new AphrontSchemaQueryException($message);
    }

    // TODO: 1064 is syntax error, and quite terrible in production.

    return null;
  }

  protected function throwConnectionException($errno, $error, $user, $host) {
    $this->throwCommonException($errno, $error);

    $message = pht(
      'Attempt to connect to %s@%s failed with error #%d: %s.',
      $user,
      $host,
      $errno,
      $error);

    throw new AphrontConnectionQueryException($message, $errno);
  }


  protected function throwQueryCodeException($errno, $error) {
    $this->throwCommonException($errno, $error);

    $message = pht(
      '#%d: %s',
      $errno,
      $error);

    throw new AphrontQueryException($message, $errno);
  }

  /**
   * Force the next query to fail with a simulated error. This should be used
   * ONLY for unit tests.
   */
  public function simulateErrorOnNextQuery($error) {
    $this->nextError = $error;
    return $this;
  }

  /**
   * Check inserts for characters outside of the BMP. Even with the strictest
   * settings, MySQL will silently truncate data when it encounters these, which
   * can lead to data loss and security problems.
   */
  protected function validateUTF8String($string) {
    if (phutil_is_utf8($string)) {
      return;
    }

    throw new AphrontCharacterSetQueryException(
      pht(
        'Attempting to construct a query using a non-utf8 string when '.
        'utf8 is expected. Use the `%%B` conversion to escape binary '.
        'strings data.'));
  }

}
