<?php
/**
 * @package DBCAPI
 */

class DeathByCaptcha_Exception extends Exception
{}


class DeathByCaptcha_RuntimeException extends DeathByCaptcha_Exception
{}


class DeathByCaptcha_ServerException extends DeathByCaptcha_Exception
{}


class DeathByCaptcha_ClientException extends DeathByCaptcha_Exception
{}


class DeathByCaptcha_InvalidAccountException extends DeathByCaptcha_ClientException
{}


class DeathByCaptcha_InvalidCaptchaException extends DeathByCaptcha_ClientException
{}


/**
 * Death by Captcha API Client
 *
 * @property-read float|false $balance User's balance (in US cents)
 * @property-read int $status Last API call status (error) code
 * @property-read int $errno Alias for {@link ::$status}
 * @property-read int $error Alias for {@link ::$status}
 *
 * @package DBCAPI
 * @subpackage PHP
 */
class DeathByCaptcha_Client
{
    const API_VERSION        = 'DBC/PHP v3.0';
    const SOFTWARE_VENDOR_ID = 0;

    const MAX_CAPTCHA_FILESIZE = 131072;

    const POLLS_COUNT    = 4;
    const POLLS_PERIOD   = 15;
    const POLLS_INTERVAL = 5;

    const DEFAULT_TIMEOUT = 60;


    protected $_api_host = 'deathbycaptcha.com';
    protected $_api_ports = array(8123, 8130);
    protected $_userpass = array();
    protected $_response = array('status' => 0xff);


    /**
     * Verbosity flag
     *
     * @var bool
     */
    public $is_verbose = false;


    /**
     * Opens a socket connection to the API server
     *
     * @param string $host API server's IP
     * @param int $port API server's port
     * @return resource Opened socket
     * @throws DeathByCaptcha_RuntimeException On network related errors
     */
    protected function _connect($host, $port)
    {
        if (!$socket = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) {
            throw new DeathByCaptcha_RuntimeException(
                'Failed creating a socket'
            );
        }
        try {
            if (!@socket_connect($socket, $host, $port)) {
                throw new DeathByCaptcha_RuntimeException(
                    'Failed connecting to the API server'
                );
            } else if (!@socket_set_nonblock($socket)) {
                throw new DeathByCaptcha_RuntimeException(
                    'Failed making the socket non-blocking'
                );
            }
        } catch (Exception $e) {
            @socket_close($socket);
            throw $e;
        }
        return $socket;
    }

    /**
     * Dumps a message, variable (serialized) or object string to stderr
     *
     * @param mixed $msg
     */
    protected function _dump($msg)
    {
        if ($this->is_verbose) {
            $m = '__toString';
            fputs(STDERR, (string)time() . ' ' . (
                (is_object($msg) && method_exists($msg, $m))
                    ? $msg->{$m}()
                    : (is_array($msg)
                          ? serialize($msg)
                          : (string)$msg)
            ) . "\n");
        }
        return $this;
    }

    /**
     * socket_send() wrapper
     *
     * @param resource $socket Socket to use
     * @param array|string $buff Data to send
     * @throws DeathByCaptcha_RuntimeException If the data wasn't sent in full
     */
    protected function _send($socket, $buff)
    {
        if (is_array($buff) || is_object($buff)) {
            $buff = json_encode($buff);
        }
        $this->_dump("SEND: {$buff}");
        while ($buff) {
            $wr = array($socket);
            $rd = $ex = null;
            if (!@socket_select($rd, $wr, $ex, self::DEFAULT_TIMEOUT) || !count($wr)) {
                break;
            }
            while ($i = @socket_send($wr[0], $buff, 4096, 0)) {
                $buff = substr($buff, $i);
            }
        }
        if ($buff) {
            throw new DeathByCaptcha_RuntimeException(
                'Connection lost while sending a data'
            );
        }
        return $this;
    }

    /**
     * socket_read() wrapper
     *
     * @param resource $socket Socket to use
     * @return array|false Hash map decoded from JSON response on success
     * @throws DeathByCaptcha_RuntimeException On invalid response or timed out connection
     */
    protected function _recv($socket)
    {
        $response = $buff = '';
        $deadline = time() + self::DEFAULT_TIMEOUT;
        while (!$response && ($deadline > time())) {
            $s = null;
            $rd = array($socket);
            $wr = $ex = null;
            if (!@socket_select($rd, $wr, $ex, self::DEFAULT_TIMEOUT) || !count($rd)) {
                break;
            } else if (@socket_recv($rd[0], $s, 4096, 0)) {
                $buff .= rtrim($s);
                $response = json_decode($buff, true);
            } else if (null === $s) {
                break;
            }
        }
        $this->_dump("RECV: {$buff}");
        if (!is_array($response) || !isset($response['status'])) {
            throw new DeathByCaptcha_RuntimeException(($deadline > time())
                ? 'Connection lost while reading a response'
                : 'Connection timed out');
        }
        return $response;
    }

    /**
     * Calls an API command
     *
     * @param string $cmd API command to call
     * @param array $args API command arguments
     * @return array|null API response hash map on success
     * @throws DeathByCaptcha_InvalidAccountException On failed login attempt
     * @throws DeathByCaptcha_InvalidCaptchaException On CAPTCHA not found or failed CAPTCHA upload
     * @throws DeathByCaptcha_ServerException On API server failures
     */
    protected function _call($cmd, array $args=array())
    {
        $this->_response = array('status' => 0xff);
        $socket = $this->_connect(
            $this->_api_host,
            $this->_api_ports[array_rand($this->_api_ports)]
        );
        try {
            $this->_response = $this->_send($socket, array_merge($args, array(
                'cmd'       => &$cmd,
                'version'   => self::API_VERSION,
                'swid'      => self::SOFTWARE_VENDOR_ID,
                'username'  => &$this->_userpass[0],
                'password'  => &$this->_userpass[1],
                'is_hashed' => true,
            )))->_recv($socket);
        } catch (Exception $e) {
            @socket_close($socket);
            throw $e;
        }
        @socket_close($socket);
        $status = $this->status;
        if ((0x01 <= $status) && (0x10 > $status)) {
            throw new DeathByCaptcha_InvalidAccountException(
                'Access denied, check your credentials and/or balance'
            );
        } else if ((0x10 <= $status) && (0x20 > $status)) {
            throw new DeathByCaptcha_InvalidCaptchaException(
                'Failed uploading/fetching CAPTCHA, check its ID'
            );
        } else if (0x00 != $status) {
            throw new DeathByCaptcha_ServerException('Server error occured');
        }
        return $this->_response;
    }


    /**
     * Checks runtime environment for required extensions/function
     *
     * @param string $username DBC account username
     * @param string $password DBC account password (stored hashed)
     * @throws DeathByCaptcha_RuntimeException When run in unsuitable environment
     * @throws DeathByCaptcha_InvalidAccountException When DBC credentials are missing or empty
     */
    public function __construct($username, $password)
    {
        $this->_api_host = gethostbyname($this->_api_host);
        $this->_api_ports = range($this->_api_ports[0], $this->_api_ports[1]);
        foreach (array('json', ) as $k) {
            if (!extension_loaded($k) && !@dl($k)) {
                throw new DeathByCaptcha_RuntimeException(
                    "Required {$k} extension not found, check your PHP configuration"
                );
            }
        }
        foreach (array('sha1', 'json_encode', 'json_decode', 'base64_encode') as $k) {
            if (!function_exists($k)) {
                throw new DeathByCaptcha_RuntimeException(
                    "Required {$k}() function not found, check your PHP configuration"
                );
            }
        }
        foreach (array('username', 'password') as $k) {
            if (!$$k) {
                throw new DeathByCaptcha_InvalidAccountException(
                    "Account {$k} is missing or empty"
                );
            }
        }
        $this->_userpass = array($username, sha1($password));
    }

    /**
     * @ignore
     */
    public function __get($key)
    {
        switch ($key) {
        case 'balance':
            return $this->get_balance();

        case 'error':
        case 'errno':
        case 'status':
            return (int)@$this->_response['status'];
        }
    }

    /**
     * Returns user details
     *
     * @return array|false
     */
    public function get_user()
    {
        $this->_call('get_user');
        return !empty($this->_response['user'])
            ? array(
                'user'    => $this->_response['user'],
                'balance' => $this->_response['balance']
              )
            : false;
    }

    /**
     * Returns user's balance (in US cents)
     *
     * @return float|false
     */
    public function get_balance()
    {
        return $this->get_user()
            ? $this->_response['balance']
            : false;
    }

    /**
     * Uploads a CAPTCHA
     *
     * @param resource|string $file CAPTCHA image file or file name
     * @return int|false Uploaded CAPTCHA ID on success
     * @throws DeathByCaptcha_InvalidCaptchaException On invalid CAPTCHA file
     */
    public function upload($file)
    {
        $close_when_done = false;
        if (!is_resource($file)) {
            if (!$file || !is_file($file) || !is_readable($file)) {
                throw new DeathByCaptcha_InvalidCaptchaException(
                    "CAPTCHA image file {$file} not found or unreadable"
                );
            }
            $file = fopen($file, 'rb');
            if (!$file) {
                throw new DeathByCaptcha_InvalidCaptchaException(
                    "Failed opening CAPTCHA image file {$file}"
                );
            }
            $close_when_done = true;
        }
        $stat = fstat($file);
        try {
            if (0 == $stat['size']) {
                throw new DeathByCaptcha_InvalidCaptchaException(
                    'CAPTCHA image file is empty'
                );
            } else if (self::MAX_CAPTCHA_FILESIZE <= $stat['size']) {
                throw new DeathByCaptcha_InvalidCaptchaException(
                    'CAPTCHA image file is too big'
                );
            }
        } catch (Exception $e) {
            if ($close_when_done) {
                fclose($file);
            }
            throw $e;
        }
        $content = '';
        $pos = ftell($file);
        rewind($file);
        while (!feof($file)) {
            $content .= fread($file, 4096);
        }
        if ($close_when_done) {
            fclose($file);
        } else if (false !== $pos) {
            fseek($file, $pos);
        }
        $this->_call('upload', array('captcha' => base64_encode($content)));
        return !empty($this->_response['captcha'])
            ? $this->_response['captcha']
            : false;
    }

    /**
     * Retrieves a CAPTCHA's text if solved
     *
     * @param int $id CAPTCHA ID
     * @return string|false
     */
    public function get_text($id)
    {
        $this->_call('get_text', array('captcha' => (int)$id));
        return !empty($this->_response['text'])
            ? $this->_response['text']
            : false;
    }

    /**
     * Reports the CAPTCHA as incorrectly solved
     * (you don't have to report correctly solved CAPTCHA)
     *
     * @param int $id CAPTCHA ID
     * @return bool
     */
    public function report($id)
    {
        $this->_call('report', array('captcha' => (int)$id));
        return !@$this->_response['is_correct'];
    }

    /**
     * Removes an unsolved CAPTCHA
     *
     * @param int $id CAPTCHA ID
     * @return bool
     */
    public function remove($id)
    {
        $this->_call('remove', array('captcha' => (int)$id));
        return !@$this->_response['captcha'];
    }

    /**
     * Runs the typical sequence of actions:
     * 1) uploads a CAPTCHA image file;
     * 2) polls for decoded text;
     * 3) removes the uploaded CAPTCHA if not solved.
     *
     * @param resource|string $file CAPTCHA image file or file name
     * @param int $timeout Optional CAPTCHA solving timeout (in seconds)
     * @return array|false Uploaded CAPTCHA (ID, text) tuple if solved
     */
    public function decode($file, $timeout=self::DEFAULT_TIMEOUT)
    {
        if ($id = $this->upload($file)) {
            $attempt = 0;
            $deadline = time() + max(2 * self::POLLS_PERIOD, (int)$timeout);
            while (($deadline > time()) && empty($this->_response['text'])) {
                $attempt++;
                sleep((1 == ($attempt % self::POLLS_COUNT))
                    ? self::POLLS_PERIOD
                    : self::POLLS_INTERVAL);
                try {
                    $this->get_text($id);
                } catch (Exception $e) {
                    $this->remove($id);
                    throw $e;
                }
            }
            if (!empty($this->_response['text'])) {
                return array($id, $this->_response['text']);
            }
            $this->remove($id);
        }
        return false;
    }
}
