<?php
namespace FWIF;

use DateInterval;
use DateTime;
use DateTimeInterface;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

class FWIF {
    public const CONTENT_TYPE = 'application/x.fwif';

    public const DEFAULT              = 0;    // Default behaviour
    public const DISCARD_MILLISECONDS = 0x01; // Always exclude the millisecond component from DateTime
    public const EXCLUDE_VERSION      = 0x02; // Exclude version byte at the start of the stream

    public const TYPE_NULL      = 0;    // NULL, no data
    public const TYPE_TRUE      = 0x01; // Represents a TRUE boolean value
    public const TYPE_FALSE     = 0x02; // Represents a FALSE boolean value
    public const TYPE_INTEGER   = 0x03; // LEB128, implicit length
    public const TYPE_FLOAT     = 0x04; // double precision IEEE 754, fixed length of 8 bytes
    public const TYPE_STRING    = 0x05; // UTF-8 string, terminated with TRAILER
    public const TYPE_BUFFER    = 0x06; // Buffer with binary data, prefixed with a LEB128 length
    public const TYPE_ARRAY     = 0x07; // List of values, terminated with TRAILER
    public const TYPE_OBJECT    = 0x08; // List of values with ASCII names, terminated with TRAILER
    public const TYPE_DATETIME  = 0x09; // A gregorian year, month and day as well as an hour, minute, seconds and millisecond component, variable ranging from 4 to 7 bytes
    public const TYPE_TIMESPAN  = 0x10; // A period of time, containing days, hours, minutes, seconds and milliseconds, variable ranging from 4 to 6 bytes
    
    public const TRAILER = 0xFF; // Termination byte

    public const VERSION = 0x01; // min 1, max 254

    private const CODECS = [
        self::TYPE_NULL     => 'Null',
        self::TYPE_TRUE     => 'True',
        self::TYPE_FALSE    => 'False',
        self::TYPE_INTEGER  => 'Integer',
        self::TYPE_FLOAT    => 'Float',
        self::TYPE_STRING   => 'String',
        self::TYPE_ARRAY    => 'Array',
        self::TYPE_OBJECT   => 'Object',
        self::TYPE_BUFFER   => 'Buffer',
        self::TYPE_DATETIME => 'DateTime',
        self::TYPE_TIMESPAN => 'TimeSpan',
    ];

    private const UTF8 = '%^(?:' // https://www.w3.org/International/questions/qa-forms-utf-8.en
                        . '[\x09\x0A\x0D\x20-\x7E]'             // ASCII
                        . '|[\xC2-\xDF][\x80-\xBF]'             // non-overlong 2-byte
                        . '|\xE0[\xA0-\xBF][\x80-\xBF]'         // excluding overlongs
                        . '|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}'  // straight 3-byte
                        . '|\xED[\x80-\x9F][\x80-\xBF]'         // excluding surrogates
                        . '|\xF0[\x90-\xBF][\x80-\xBF]{2}'      // planes 1-3
                        . '|[\xF1-\xF3][\x80-\xBF]{3}'          // planes 4-15
                        . '|\xF4[\x80-\x8F][\x80-\xBF]{2}'      // plane 16 
                        . ')*$%xs';

    private static function isAssocArray($array): bool {
        if(!is_array($array) || $array === [])
            return false;
        return array_keys($array) !== range(0, count($array) - 1);
    }

    // apparently this is faster than mb_check_encoding($string, 'utf-8');
    // on PHP 7.1 on Windows at least, perhaps investigate this later
    // UPDATE TODO: does this even make any sense with other internal encodings?
    private static function isUTF8String(string $string): bool {
        return preg_match(self::UTF8, $string) === 1;
    }

    private static function detectType($data, int $flags): int {
        if(is_null($data))
            return self::TYPE_NULL;
        if(is_bool($data))
            return $data ? self::TYPE_TRUE : self::TYPE_FALSE;
        if(is_int($data))
            return self::TYPE_INTEGER;
        if(is_float($data))
            return self::TYPE_FLOAT;
        if(is_string($data))
            return self::isUTF8String($data) ? self::TYPE_STRING : self::TYPE_BUFFER;
        if(is_object($data) || self::isAssocArray($data)) {
            if($data instanceof DateTimeInterface)
                return self::TYPE_DATETIME;
            if($data instanceof DateInterval)
                return self::TYPE_TIMESPAN;
            return self::TYPE_OBJECT;
        }
        if(is_array($data))
            return self::TYPE_ARRAY;
        throw new FWIFUnsupportedTypeException(gettype($data));
    }

    public static function encode($data, int $flags = self::DEFAULT): string {
        $encoded = self::encodeInternal($data, $flags);
        if(!($flags & self::EXCLUDE_VERSION))
            $encoded = chr(self::VERSION) . $encoded;
        return $encoded;
    }
    private static function encodeInternal($data, int $flags): string {
        if($data instanceof FWIFSerializable)
            $data = $data->fwifSerialize();
        $type = self::detectType($data, $flags);
        return chr($type) . self::{'encode' . self::CODECS[$type]}($data, $flags);
    }

    public static function decode($data, int $flags = self::DEFAULT) {
        if(is_string($data)) {
            $fd = fopen('php://memory', 'rb+');
            fwrite($fd, $data);
            fseek($fd, 0);
            $data = $fd;
        }
        if(!is_resource($data))
            throw new InvalidArgumentException('$data must be either a string or a file handle.');
        if(!($flags & self::EXCLUDE_VERSION)) {
            $version = ord(fgetc($data));
            if($version < 1 || $version > 254)
                throw new InvalidArgumentException('$data is not a valid FWIF serialized stream.');
            if($version > self::VERSION)
                throw new FWIFUnsupportedVersionException;
        }
        $decoded = self::decodeInternal($data, $flags);
        if(isset($fd))
            fclose($fd);
        return $decoded;
    }
    private static function decodeInternal($data, int $flags) {
        $type = ord(fgetc($data));
        if(!array_key_exists($type, self::CODECS)) {
            $hexType = dechex($type); $pos = ftell($data); $hexPos = dechex($pos);
            throw new FWIFUnsupportedTypeException("Unsupported type {$type} (0x{$hexType}) at position {$pos} (0x{$hexPos})");
        }
        return self::{'decode' . self::CODECS[$type]}($data, $flags);
    }

    private static function encodeNull($data, int $flags): string { return ''; }
    private static function decodeNull($data, int $flags) { return null; }

    private static function encodeTrue($data, int $flags): string { return ''; }
    private static function decodeTrue($data, int $flags): bool { return true; }

    private static function encodeFalse($data, int $flags): string { return ''; }
    private static function decodeFalse($data, int $flags): bool { return false; }

    private static function encodeInteger(int $number, int $flags): string {
        $packed = ''; $more = true; $negative = $number < 0; $size = PHP_INT_SIZE * 8;
        while($more) {
            $byte = $number & 0x7F;
            $number >>= 7;
            if($negative)
                $number |= (~0 << ($size - 7));
            if((!$number && !($byte & 0x40)) || ($number === -1 && ($byte & 0x40)))
                $more = false;
            else
                $byte |= 0x80;
            $packed .= chr($byte);
        }
        return $packed;
    }
    private static function decodeInteger($data, int $flags): int {
        $number = 0; $shift = 0; $size = PHP_INT_SIZE * 8;
        do {
            $byte = ord(fgetc($data));
            $number |= ($byte & 0x7F) << $shift;
            $shift += 7;
        } while($byte & 0x80);
        if(($shift < $size) && ($byte & 0x40))
            $number |= (~0 << $shift);
        return $number;
    }

    private const FLOAT_MZ   = 0x04;
    private const FLOAT_EZ   = 0x08;
    private const FLOAT_ZERO = self::FLOAT_MZ | self::FLOAT_EZ;
    private const FLOAT_NAN  = 0x20;
    private const FLOAT_INF  = 0x40;
    private const FLOAT_SIGN = 0x80;
    private const FLOAT_NINF = self::FLOAT_INF | self::FLOAT_SIGN;

    private const FLOAT_DIV = 0x7FFFFFFF;

    private static function encodeFloat(float $number, int $flags): string {
        if(is_nan($number))
            $packed = chr(self::FLOAT_NAN);
        elseif($number === INF)
            $packed = chr(self::FLOAT_INF);
        elseif($number === -INF)
            $packed = chr(self::FLOAT_NINF);
        else {
            $byte1 = 0; $packed = '';

            if($number < 0.0 || ($number ** -1 === -INF))
                $byte1 |= self::FLOAT_SIGN;

            if($number === 0.0)
                $byte1 |= self::FLOAT_ZERO;
            else {
                $numAbs = abs($number);
                $exp = (int)(floor(log($numAbs, 2)) + 1);
                $man = (int)(($numAbs * (2 ** -$exp)) * self::FLOAT_DIV);
                if($exp != 0)
                    $packed .= self::encodeInteger($exp, $flags);
                else
                    $byte1 |= self::FLOAT_EZ;
                if($man > 0)
                    $packed .= pack('N', $man);
                else
                    $byte1 |= self::FLOAT_MZ;
            }

            $packed = chr($byte1) . $packed;
        }

        return $packed;
    }
    private static function decodeFloat($data, int $flags): float {
        $byte1 = ord(fgetc($data));

        if($byte1 & self::FLOAT_NAN)
            return NAN;
        if($byte1 & self::FLOAT_INF)
            return ($byte1 & self::FLOAT_SIGN) ? -INF : INF;
        if(($byte1 & self::FLOAT_ZERO) === self::FLOAT_ZERO)
            return ($byte1 & self::FLOAT_SIGN) ? -0.0 : 0.0;

        $exp = 0; $man = 0;

        if(!($byte1 & self::FLOAT_EZ))
            $exp = self::decodeInteger($data, $flags);
        if(!($byte1 & self::FLOAT_MZ))
            $man = ((float)unpack('N', fread($data, 4))[1]) / self::FLOAT_DIV;

        $number = $man * (2 ** $exp);
        if($byte1 & self::FLOAT_SIGN)
            $number *= -1;

        return $number;
    }

    private static function encodeString(string $string, int $flags): string {
        $packed = '';
        $string = unpack('C*', mb_convert_encoding($string, 'utf-8', mb_internal_encoding()));
        foreach($string as $char)
            $packed .= chr($char);
        return $packed . chr(self::TRAILER);
    }
    private static function decodeString($data, int $flags): string {
        $packed = '';
        for(;;) {
            $char = fgetc($data); $byte = ord($char);
            if($byte == self::TRAILER)
                break;
            $packed .= $char;
            if(($byte & 0xF8) == 0xF0)
                $packed .= fread($data, 3);
            elseif(($byte & 0xF0) == 0xE0)
                $packed .= fread($data, 2);
            elseif(($byte & 0xE0) == 0xC0)
                $packed .= fgetc($data);
        }
        return mb_convert_encoding($packed, mb_internal_encoding(), 'utf-8');
    }

    private static function encodeArray(array $array, int $flags): string {
        $packed = '';
        foreach($array as $value)
            $packed .= self::encodeInternal($value, $flags);
        return $packed . chr(self::TRAILER);
    }
    private static function decodeArray($data, int $flags): array {
        $array = [];
        for(;;) {
            if(ord(fgetc($data)) === self::TRAILER)
                break;
            fseek($data, -1, SEEK_CUR);
            $array[] = self::decodeInternal($data, $flags);
        }
        return $array;
    }

    private static function encodeObject($object, int $flags): string {
        $packed = ''; $array = (array)$object;
        foreach($array as $name => $value)
            $packed .= mb_convert_encoding($name, 'us-ascii', mb_internal_encoding()) . chr(self::TRAILER) . self::encodeInternal($value, $flags);
        return $packed . chr(self::TRAILER);
    }
    private static function decodeObjectKey($data, int $flags): string {
        $packed = '';
        for(;;) {
            $char = fgetc($data);
            if(ord($char) === self::TRAILER)
                break;
            $packed .= $char;
        }
        return mb_convert_encoding($packed, mb_internal_encoding(), 'us-ascii');
    }
    private static function decodeObject($data, int $flags): object {
        $array = [];
        for(;;) {
            if(ord(fgetc($data)) === self::TRAILER)
                break;
            fseek($data, -1, SEEK_CUR);
            $array[self::decodeObjectKey($data, $flags)] = self::decodeInternal($data, $flags);
        }
        return (object)$array;
    }

    private static function encodeBuffer(string $buffer, int $flags): string {
        return self::encodeInteger(strlen($buffer), $flags) . $buffer;
    }
    private static function decodeBuffer($data, int $flags): string {
        return fread($data, self::decodeInteger($data, $flags));
    }

    private const DATETIME_FLAG_TIME  = 0x40;
    private const DATETIME_FLAG_MILLI = 0x4000;

    private const DATETIME_YEAR_SIGN  = 0x40000000;
    private const DATETIME_YEAR_MASK  = 0x3FFF;
    private const DATETIME_YEAR_SHIFT = 16; // <<

    private const DATETIME_MONTH_MASK  = 0x0F;
    private const DATETIME_MONTH_SHIFT = 12; // <<

    private const DATETIME_DAY_MASK = 0x1F;
    private const DATETIME_DAY_SHIFT = 7; // <<

    private const DATETIME_HOUR_MASK  = 0x1F;

    private const DATETIME_MINS_MASK  = 0x3F;
    private const DATETIME_MINS_SHIFT = 8; // <<

    private const DATETIME_SECS_MASK = 0x3F;
    private const DATETIME_SECS_SHIFT = 2; // <<

    private const DATETIME_MILLI_HI_MASK  = 0x300;
    private const DATETIME_MILLI_HI_SHIFT = 8; // >>
    private const DATETIME_MILLI_LO_MASK  = 0x0FF;

    /* +--------+--------+ Y - Signed 15-bit year               W - w enable flag
     * |.YYYYYYY|YYYYYYYY| M - Unsigned 4-bit month             m - unsigned 6-bit minutes
     * |MMMMDDDD|DT.HHHHH| D - Unsigned 5-bit Day               S - unsigned 6-bit seconds
     * |.Wmmmmmm|SSSSSSww| T - WmSw enable flag                 w - unsigned 10-bit millisecs
     * |wwwwwwww|        | H - Unsigned 5-bit hours
     * +--------+--------+
     */

    private static function encodeDateTime(DateTimeInterface $dt, int $flags): string {
        static $utc = null;
        if($utc === null)
            $utc = new DateTimeZone('utc');

        if($dt->getTimezone()->getOffset($dt) !== 0)
            $dt = DateTime::createFromInterface($dt)->setTimezone($utc);

        $year   = (int)$dt->format('Y');
        $month  = (int)$dt->format('n');
        $day    = (int)$dt->format('j');
        $hours  = (int)$dt->format('G');
        $mins   = (int)$dt->format('i');
        $secs   = (int)$dt->format('s');
        $millis = ($flags & self::DISCARD_MILLISECONDS) ? 0 : (int)$dt->format('v');

        $subYear = $year < 0;
        if($subYear)
            $year = ~$year;
        
        $ymdh  = $subYear ? self::DATETIME_YEAR_SIGN : 0;
        $ymdh |= ($year  & self::DATETIME_YEAR_MASK)  << self::DATETIME_YEAR_SHIFT;
        $ymdh |= ($month & self::DATETIME_MONTH_MASK) << self::DATETIME_MONTH_SHIFT;
        $ymdh |= ($day   & self::DATETIME_DAY_MASK)   << self::DATETIME_DAY_SHIFT;
        $ymdh |= ($hours & self::DATETIME_HOUR_MASK);

        if($mins > 0 || $secs > 0 || $millis > 0) {
            $ymdh |= self::DATETIME_FLAG_TIME;
            $msw   = 0;
            $msw  |= ($mins  & self::DATETIME_MINS_MASK) << self::DATETIME_MINS_SHIFT;
            $msw  |= ($secs  & self::DATETIME_SECS_MASK) << self::DATETIME_SECS_SHIFT;

            if($millis > 0) {
                $msw |=  self::DATETIME_FLAG_MILLI;
                $msw |= ($millis & self::DATETIME_MILLI_HI_MASK) >> self::DATETIME_MILLI_HI_SHIFT;
                $w    =  $millis & self::DATETIME_MILLI_LO_MASK;
            }
        }

        $packed = pack('N', $ymdh);
        if($ymdh & self::DATETIME_FLAG_TIME) {
            $packed .= pack('n', $msw);
            if($msw & self::DATETIME_FLAG_MILLI)
                $packed .= chr($w);
        }

        return $packed;
    }
    private static function decodeDateTime($data, int $flags): DateTimeInterface {
        $ymdh   = unpack('N', fread($data, 4))[1];
        $years  = ($ymdh >> self::DATETIME_YEAR_SHIFT)  & self::DATETIME_YEAR_MASK;
        $months = ($ymdh >> self::DATETIME_MONTH_SHIFT) & self::DATETIME_MONTH_MASK;
        $days   = ($ymdh >> self::DATETIME_DAY_SHIFT)   & self::DATETIME_DAY_MASK;
        $hours  =  $ymdh                                & self::DATETIME_HOUR_MASK;

        if($ymdh & self::DATETIME_YEAR_SIGN)
            $years = ~$years;
        $dt = sprintf('%04d-%02d-%02dT%02d:', $years, $months, $days, $hours);

        if($ymdh & self::DATETIME_FLAG_TIME) {
            $msw = unpack('n', fread($data, 2))[1];
            $mins = ($msw >> self::DATETIME_MINS_SHIFT) & self::DATETIME_MINS_MASK;
            $secs = ($msw >> self::DATETIME_SECS_SHIFT) & self::DATETIME_SECS_MASK;
            $dt .= sprintf('%02d:%02d', $mins, $secs);
            if($msw & self::DATETIME_FLAG_MILLI) {
                $millis  = ($msw << self::DATETIME_MILLI_HI_SHIFT) & self::DATETIME_MILLI_HI_MASK;
                $millis |=  ord(fgetc($data));
                $dt .= sprintf('.%03d', $millis);
            }
        } else $dt .= '00:00';

        return new DateTimeImmutable($dt . 'UTC');
    }

    private const TIMESPAN_FLAG_DAYS = 0x20000000;
    private const TIMESPAN_FLAG_NEGA = 0x40000000;

    private const TIMESPAN_MILLI_SHIFT = 16; // <<
    private const TIMESPAN_MILLI_MASK  = 0x3FF;

    private const TIMESPAN_SECS_SHIFT = 11; // <<
    private const TIMESPAN_MINS_SHIFT = 5; // <<

    private const TIMESPAN_DAYS_MASK = 0x7FFF;

    private const TIMESPAN_YEAR_DAYS = 365.0;
    private const TIMESPAN_MONTH_DAYS = 31.0;

    /* +--------+--------+ w - unsigned 10-bit millisecs    n - Negative flag
     * |.nd..www|wwwwwwwS| S - unsigned 6-bit seconds       d - DMY enable flag
     * |SSSSSmmm|mmmHHHHH| m - unsigned 6-bit minutes       D - unsigned 15-bit day
     * |.DDDDDDD|DDDDDDDD| H - unsigned 5-bit hours
     * +--------+--------+ 
     */

    private static function encodeTimeSpan(DateInterval $di, int $flags): string {
        $wsmh  = $di->invert ? self::TIMESPAN_FLAG_NEGA : 0;
        $wsmh |= ((int)($di->f * 1000) & self::TIMESPAN_MILLI_MASK) << self::TIMESPAN_MILLI_SHIFT;
        $wsmh |= (      $di->s         & self::DATETIME_SECS_MASK)  << self::TIMESPAN_SECS_SHIFT;
        $wsmh |= (      $di->i         & self::DATETIME_MINS_MASK)  << self::TIMESPAN_MINS_SHIFT;
        $wsmh |= (      $di->h         & self::DATETIME_HOUR_MASK);

        $days = $di->days;
        if($days === false) // Best I can come up with on short notice, fuck it
            $days = (int)($di->d + ($di->m * self::TIMESPAN_YEAR_DAYS) + ($di->y * self::TIMESPAN_MONTH_DAYS));

        if($days > 0) {
            $wsmh |= self::TIMESPAN_FLAG_DAYS;
            $days &= self::TIMESPAN_DAYS_MASK;
        }

        $packed = pack('N', $wsmh);
        if($wsmh & self::TIMESPAN_FLAG_DAYS)
            $packed .= pack('n', $days);

        return $packed;
    }
    private static function decodeTimeSpan($data, int $flags): DateInterval {
        $di = new DateInterval('P0Y');

        $wsmh       = unpack('N', fread($data, 4))[1];
        $di->invert = ($wsmh  & self::TIMESPAN_FLAG_NEGA) ? 1 : 0;
        $di->f      = (($wsmh >> self::TIMESPAN_MILLI_SHIFT) & self::TIMESPAN_MILLI_MASK) / 1000.0;
        $di->s      =  ($wsmh >> self::TIMESPAN_SECS_SHIFT)  & self::DATETIME_SECS_MASK;
        $di->i      =  ($wsmh >> self::TIMESPAN_MINS_SHIFT)  & self::DATETIME_MINS_MASK;
        $di->h      =   $wsmh                                & self::DATETIME_HOUR_MASK;

        if($wsmh & self::TIMESPAN_FLAG_DAYS) {
            $days = unpack('n', fread($data, 2))[1] & self::TIMESPAN_DAYS_MASK;
            $di->days = $days;
            $di->y = (int)floor($days / self::TIMESPAN_YEAR_DAYS);
            $days -= $di->y * self::TIMESPAN_YEAR_DAYS;
            $di->m = (int)ceil($days / self::TIMESPAN_MONTH_DAYS);
            $di->d = max(0, $days - ($di->m * self::TIMESPAN_MONTH_DAYS));
        }

        return $di;
    }
}