<?php
/** @license MIT
 * Copyright 2017 J. King, Dustin Wilson et al.
 * See LICENSE and AUTHORS files for details */

declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;

use JKingWeb\Arsse\ExceptionType;

class ValueInfo {
    // universal
    public const VALID = 1 << 0;
    public const NULL = 1 << 1;
    // integers
    public const ZERO = 1 << 2;
    public const NEG = 1 << 3;
    public const FLOAT = 1 << 4;
    // strings
    public const EMPTY = 1 << 2;
    public const WHITE = 1 << 3;
    // normalization types
    public const T_MIXED = 0; // pass through unchanged
    public const T_NULL = 1; // convert to null
    public const T_BOOL = 2; // convert to boolean
    public const T_INT = 3; // convert to integer
    public const T_FLOAT = 4; // convert to floating point
    public const T_DATE = 5; // convert to DateTimeInterface instance
    public const T_STRING = 6; // convert to string
    public const T_ARRAY = 7; // convert to array
    public const T_INTERVAL = 8; // convert to time interval
    // normalization modes
    public const M_LOOSE = 0;
    public const M_NULL = 1 << 28; // pass nulls through regardless of target type
    public const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match
    public const M_STRICT = 1 << 30; // throw an exception if the type doesn't match
    public const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable
    public const TYPE_NAMES = [
        self::T_MIXED    => "mixed",
        self::T_NULL     => "null",
        self::T_BOOL     => "boolean",
        self::T_INT      => "integer",
        self::T_FLOAT    => "float",
        self::T_DATE     => "date",
        self::T_STRING   => "string",
        self::T_ARRAY    => "array",
        self::T_INTERVAL => "interval",
    ];
    // symbolic date and time formats
    protected const DATE_FORMATS = [ // in            out
        'iso8601'   => ["!Y-m-d\TH:i:s",          "Y-m-d\TH:i:s\Z"       ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
        'iso8601m'  => ["!Y-m-d\TH:i:s.u",        "Y-m-d\TH:i:s.u\Z"     ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
        'microtime' => ["U.u",                    "0.u00 U"              ], // NOTE: the actual input format at the user level matches the output format; pre-processing is required for PHP not to fail
        'http'      => ["!D, d M Y H:i:s \G\M\T", "D, d M Y H:i:s \G\M\T"],
        'sql'       => ["!Y-m-d H:i:s",           "Y-m-d H:i:s"          ],
        'date'      => ["!Y-m-d",                 "Y-m-d"                ],
        'time'      => ["!H:i:s",                 "H:i:s"                ],
        'unix'      => ["U",                      "U"                    ],
        'float'     => ["U.u",                    "U.u"                  ],
    ];

    public static function normalize($value, int $type, string $dateInFormat = null, $dateOutFormat = null) {
        $allowNull = ($type & self::M_NULL);
        $strict = ($type & (self::M_STRICT | self::M_DROP));
        $drop = ($type & self::M_DROP);
        $arrayVal = ($type & self::M_ARRAY);
        $type = ($type & ~(self::M_NULL | self::M_DROP | self::M_STRICT | self::M_ARRAY));
        // if the value is null and this is allowed, simply return
        if ($allowNull && is_null($value)) {
            return null;
        }
        // if the value is supposed to be an array, handle it specially
        if ($arrayVal) {
            $value = self::normalize($value, self::T_ARRAY);
            foreach ($value as $key => $v) {
                $value[$key] = self::normalize($v, $type | ($allowNull ? self::M_NULL : 0) | ($strict ? self::M_STRICT : 0) | ($drop ? self::M_DROP : 0), $dateInFormat, $dateOutFormat);
            }
            return $value;
        }
        switch ($type) {
            case self::T_MIXED:
                return $value;
            case self::T_NULL:
                return null;
            case self::T_BOOL:
                if (is_bool($value)) {
                    return $value;
                }
                $out = self::bool($value);
                if ($strict && is_null($out)) {
                    // if strict and input is not a boolean, this is an error
                    if ($drop) {
                        return null;
                    }
                    throw new ExceptionType("strictFailure", $type);
                } elseif (is_float($value) && is_nan($value)) {
                    return false;
                } elseif (is_null($out)) {
                    // if not strict and input is not a boolean, return a simple type-cast
                    return (bool) $value;
                }
                return $out;
            case self::T_INT:
                if (is_int($value)) {
                    return $value;
                } elseif ($value instanceof \DateTimeInterface) {
                    if ($strict && !$drop) {
                        throw new ExceptionType("strictFailure", $type);
                    }
                    return (!$drop) ? (int) $value->getTimestamp(): null;
                } elseif ($value instanceof \DateInterval) {
                    if ($strict && !$drop) {
                        throw new ExceptionType("strictFailure", $type);
                    } elseif ($drop) {
                        return null;
                    } else {
                        // returns the number of seconds in the interval
                        // days are assumed to contain (60 * 60 * 24) seconds
                        // months are assumed to contain 30 days
                        // years are assumed to contain 365 days
                        $s = 0;
                        if ($value->days !== false) {
                            $s += ($value->days * 24 * 60 * 60);
                        } else {
                            $s += ($value->y * 365 * 24 * 60 * 60);
                            $s += ($value->m * 30 * 24 * 60 * 60);
                            $s += ($value->d * 24 * 60 * 60);
                        }
                        $s += ($value->h * 60 * 60);
                        $s += ($value->i * 60);
                        $s += $value->s;
                        return $s;
                    }
                }
                $info = self::int($value);
                if ($strict && !($info & self::VALID)) {
                    // if strict and input is not an integer, this is an error
                    if ($drop) {
                        return null;
                    }
                    throw new ExceptionType("strictFailure", $type);
                } elseif (is_bool($value)) {
                    return (int) $value;
                } elseif ($info & (self::VALID | self::FLOAT)) {
                    $out = strtolower((string) $value);
                    if (strpos($out, "e")) {
                        return (int) (float) $out;
                    } else {
                        return (int) $out;
                    }
                } else {
                    return 0;
                }
                break; // @codeCoverageIgnore
            case self::T_FLOAT:
                if (is_float($value)) {
                    return $value;
                } elseif ($value instanceof \DateTimeInterface) {
                    if ($strict && !$drop) {
                        throw new ExceptionType("strictFailure", $type);
                    }
                    return (!$drop) ? (float) $value->getTimestamp(): null;
                } elseif ($value instanceof \DateInterval) {
                    if ($drop) {
                        return null;
                    } elseif ($strict) {
                        throw new ExceptionType("strictFailure", $type);
                    }
                    // convert the interval to an integer, and then add microseconds if available (since PHP 7.1, for intervals created from a DateTime difference operation)
                    $out = (float) self::normalize($value, self::T_INT);
                    $out += isset($value->f) ? $value->f : 0.0;
                    return $out;
                } elseif (is_bool($value) && $strict) {
                    if ($drop) {
                        return null;
                    }
                    throw new ExceptionType("strictFailure", $type);
                }
                $out = filter_var($value, \FILTER_VALIDATE_FLOAT);
                if ($strict && $out === false) {
                    // if strict and input is not a float, this is an error
                    if ($drop) {
                        return null;
                    }
                    throw new ExceptionType("strictFailure", $type);
                }
                return (float) $out;
            case self::T_STRING:
                if (is_string($value)) {
                    return $value;
                }
                if ($value instanceof \DateTimeInterface) {
                    $dateOutFormat = $dateOutFormat ?? "iso8601";
                    $dateOutFormat = isset(self::DATE_FORMATS[$dateOutFormat]) ? self::DATE_FORMATS[$dateOutFormat][1] : $dateOutFormat;
                    if ($value instanceof \DateTimeImmutable) {
                        return $value->setTimezone(new \DateTimeZone("UTC"))->format($dateOutFormat);
                    } elseif ($value instanceof \DateTime) {
                        return \DateTimeImmutable::createFromMutable($value)->setTimezone(new \DateTimeZone("UTC"))->format($dateOutFormat);
                    }
                } elseif ($value instanceof \DateInterval) {
                    $dateSpec = "";
                    $timeSpec = "";
                    if ($value->days) {
                        $dateSpec = $value->days."D";
                    } else {
                        $dateSpec .= $value->y ? $value->y."Y": "";
                        $dateSpec .= $value->m ? $value->m."M": "";
                        $dateSpec .= $value->d ? $value->d."D": "";
                    }
                    $timeSpec .= $value->h ? $value->h."H": "";
                    $timeSpec .= $value->i ? $value->i."M": "";
                    $timeSpec .= $value->s ? $value->s."S": "";
                    $timeSpec = $timeSpec ? "T".$timeSpec : "";
                    if (!$dateSpec && !$timeSpec) {
                        return "PT0S";
                    } else {
                        return "P".$dateSpec.$timeSpec;
                    }
                } elseif (is_float($value) && is_finite($value)) {
                    $out = (string) $value;
                    if (!strpos($out, "E")) {
                        return $out;
                    } else {
                        $out = sprintf("%F", $value);
                        return preg_match("/\.0{1,}$/", $out) ? (string) (int) $out : $out;
                    }
                }
                $info = self::str($value);
                if (!($info & self::VALID)) {
                    if ($drop) {
                        return null;
                    } elseif ($strict) {
                        // if strict and input is not a string, this is an error
                        throw new ExceptionType("strictFailure", $type);
                    } elseif (!is_scalar($value)) {
                        return "";
                    } else {
                        return (string) $value;
                    }
                } else {
                    return (string) $value;
                }
                break; // @codeCoverageIgnore
            case self::T_DATE:
                if ($value instanceof \DateTimeImmutable) {
                    return $value->setTimezone(new \DateTimeZone("UTC"));
                } elseif ($value instanceof \DateTime) {
                    return \DateTimeImmutable::createFromMutable($value)->setTimezone(new \DateTimeZone("UTC"));
                } elseif (is_int($value)) {
                    return \DateTimeImmutable::createFromFormat("U", (string) $value, new \DateTimeZone("UTC"));
                } elseif (is_float($value) && is_finite($value)) {
                    return \DateTimeImmutable::createFromFormat("U.u", sprintf("%F", $value), new \DateTimeZone("UTC"));
                } elseif (is_string($value)) {
                    try {
                        if (!is_null($dateInFormat)) {
                            $out = false;
                            if ($dateInFormat === "microtime") {
                                // PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float
                                if (preg_match("<^0\.\d{6}00 \d+$>", $value)) {
                                    $value = substr($value, 11).".".substr($value, 2, 6);
                                } else {
                                    throw new \Exception;
                                }
                            }
                            $f = isset(self::DATE_FORMATS[$dateInFormat]) ? self::DATE_FORMATS[$dateInFormat][0] : $dateInFormat;
                            if ($dateInFormat === "iso8601" || $dateInFormat === "iso8601m") {
                                // DateTimeImmutable::createFromFormat() doesn't provide one catch-all for ISO 8601 timezone specifiers, so we try all of them till one works
                                if ($dateInFormat === "iso8601m") {
                                    $f2 = self::DATE_FORMATS["iso8601"][0];
                                    $zones = [$f."", $f."\Z", $f."P", $f."O", $f2."", $f2."\Z", $f2."P", $f2."O"];
                                } else {
                                    $zones = [$f."", $f."\Z", $f."P", $f."O"];
                                }
                                do {
                                    $ftz = array_shift($zones);
                                    $out = \DateTimeImmutable::createFromFormat($ftz, $value, new \DateTimeZone("UTC"));
                                } while (!$out && $zones);
                            } else {
                                $out = \DateTimeImmutable::createFromFormat($f, $value, new \DateTimeZone("UTC"));
                            }
                            if (!$out) {
                                throw new \Exception;
                            }
                            return $out;
                        } else {
                            return new \DateTimeImmutable($value, new \DateTimeZone("UTC"));
                        }
                    } catch (\Exception $e) {
                        if ($strict && !$drop) {
                            throw new ExceptionType("strictFailure", $type);
                        }
                        return null;
                    }
                } elseif ($strict && !$drop) {
                    throw new ExceptionType("strictFailure", $type);
                }
                return null;
            case self::T_ARRAY:
                if (is_array($value)) {
                    return $value;
                } elseif ($value instanceof \Traversable) {
                    $out = [];
                    foreach ($value as $k => $v) {
                        $out[$k] = $v;
                    }
                    return $out;
                } else {
                    if ($drop) {
                        return null;
                    } elseif ($strict) {
                        // if strict and input is not a string, this is an error
                        throw new ExceptionType("strictFailure", $type);
                    } elseif (is_null($value) || (is_float($value) && is_nan($value))) {
                        return [];
                    } else {
                        return [$value];
                    }
                }
                break; // @codeCoverageIgnore
            case self::T_INTERVAL:
                if ($value instanceof \DateInterval) {
                    if ($value->invert) {
                        $value = clone $value;
                        $value->invert = 0;
                    }
                    $value->f = $value->f ?? 0.0; // add microseconds for PHP 7.0
                    return $value;
                } elseif (is_null($value)) {
                    if ($strict && !$drop && !$allowNull) {
                        throw new ExceptionType("strictFailure", $type);
                    } else {
                        return null;
                    }
                } elseif (is_bool($value) || is_array($value) || (is_float($value) && (is_infinite($value) || is_nan($value))) || $value instanceof \DateTimeInterface || (is_object($value) && !method_exists($value, "__toString"))) {
                    if ($strict && !$drop) {
                        throw new ExceptionType("strictFailure", $type);
                    } else {
                        return null;
                    }
                } elseif (is_string($value) || is_object($value)) {
                    try {
                        $out = new \DateInterval((string) $value);
                        $out->f = 0.0;
                        return $out;
                    } catch (\Exception $e) {
                        if ($strict && !$drop) {
                            throw new ExceptionType("strictFailure", $type);
                        } elseif ($drop) {
                            return null;
                        } elseif (strtotime("now + $value") !== false) {
                            $out = \DateInterval::createFromDateString($value);
                            $out->f = 0.0;
                            return $out;
                        } else {
                            return null;
                        }
                    }
                } elseif ($drop) {
                    return null;
                } elseif ($strict) {
                    throw new ExceptionType("strictFailure", $type);
                } else {
                    // input is a number, assume this is a number of seconds
                    // for legibility we convert large numbers to minutes, hours, and days as necessary
                    // the DateInterval constructor only allows 12 digits for any given part of an interval,
                    // so we also convert days to 365-day years where we must, and cap the number of years
                    // at (1e11 - 1); this being a very large number, the loss of precision is probably not
                    // significant in practical usage
                    $sec = abs($value);
                    $msec = (float) ($sec - (int) $sec);
                    $sec = (int) $sec;
                    $min = 0;
                    $hour = 0;
                    $day = 0;
                    $year = 0;
                    if ($sec >= 60) {
                        $min = ($sec - ($sec % 60)) / 60;
                        $sec %= 60;
                    }
                    if ($min >= 60) {
                        $hour = ($min - ($min % 60)) / 60;
                        $min %= 60;
                    }
                    if ($hour >= 24) {
                        $day = ($hour - ($hour % 24)) / 24;
                        $hour %= 24;
                    }
                    if ($day >= 999999999999) {
                        $year = ($day - ($day % 365)) / 365;
                        $day %= 365;
                    }
                    $spec = "P";
                    $spec .= $year ? $year."Y" : "";
                    $spec .= $day ? $day."D" : "";
                    $spec .= "T";
                    $spec .= $hour ? $hour."H" : "";
                    $spec .= $min ? $min."M" : "";
                    $spec .= $sec ? $sec."S" : "";
                    $spec .= ($spec === "PT") ? "0S" : "";
                    $spec = trim($spec, "T");
                    $out = new \DateInterval($spec);
                    $out->f = $msec;
                    return $out;
                }
                break; // @codeCoverageIgnore
            default:
                throw new ExceptionType("typeUnknown", $type); // @codeCoverageIgnore
        }
    }

    public static function flatten(array $arr): array {
        $arr = array_values($arr);
        for ($a = 0; $a < sizeof($arr); $a++) {
            if (is_array($arr[$a])) {
                array_splice($arr, $a, 1, $arr[$a]);
                $a--;
            }
        }
        return $arr;
    }

    public static function int($value): int {
        $out = 0;
        if (is_null($value)) {
            // check if the input is null
            return self::NULL;
        } elseif (is_string($value) || (is_object($value) && method_exists($value, "__toString"))) {
            $value = strtolower((string) $value);
            // normalize a string an integer or float if possible
            if (!strlen($value)) {
                // the empty string is equivalent to null when evaluating an integer
                return self::NULL;
            }
            // interpret the value as a float
            $float = filter_var($value, \FILTER_VALIDATE_FLOAT);
            if ($float !== false) {
                if (!fmod($float, 1)) {
                    // an integral float is acceptable
                    $value = (int) (!strpos($value, "e") ? $value : $float);
                } else {
                    $out += self::FLOAT;
                    $value = $float;
                }
            } else {
                return $out;
            }
        } elseif (is_float($value)) {
            if (!fmod($value, 1)) {
                // an integral float is acceptable
                $value = (int) $value;
            } else {
                $out += self::FLOAT;
            }
        } elseif (!is_int($value)) {
            // if the value is not an integer or integral float, stop
            return $out;
        }
        // mark validity
        if (is_int($value)) {
            $out += self::VALID;
        }
        // mark zeroness
        if (!$value) {
            $out += self::ZERO;
        }
        // mark negativeness
        if ($value < 0) {
            $out += self::NEG;
        }
        return $out;
    }

    public static function str($value): int {
        $out = 0;
        // check if the input is null
        if (is_null($value)) {
            $out += self::NULL;
        }
        if (is_object($value) && method_exists($value, "__toString")) {
            // if the value is an object which has a __toString method, this is acceptable
            $value = (string) $value;
        } elseif (!is_scalar($value) || is_bool($value) || (is_float($value) && !is_finite($value))) {
            // otherwise if the value is not scalar, is a boolean, or is infinity or NaN, it cannot be valid
            return $out;
        }
        // mark validity
        $out += self::VALID;
        if (!strlen((string) $value)) {
            // mark emptiness
            $out += self::EMPTY;
        } elseif (!strlen(trim((string) $value))) {
            // mark whitespacedness
            $out += self::WHITE;
        }
        return $out;
    }

    public static function id($value, bool $allowNull = false): bool {
        $info = self::int($value);
        if ($allowNull && ($info & self::NULL)) { // null (and allowed)
            return true;
        } elseif (!($info & self::VALID)) { // not an integer
            return false;
        } elseif ($info & self::NEG) { // negative integer
            return false;
        } elseif (!$allowNull && ($info & self::ZERO)) { // zero (and not allowed)
            return false;
        } else { // non-negative integer
            return true;
        }
    }

    public static function bool($value, bool $default = null): ?bool {
        if (is_null($value) || ValueInfo::str($value) & ValueInfo::WHITE) {
            return $default;
        }
        $out = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
        if (is_null($out) && (ValueInfo::int($value) & ValueInfo::VALID)) {
            $out = (int) filter_var($value, \FILTER_VALIDATE_FLOAT);
            return ($out == 1 || $out == 0) ? (bool) $out : $default;
        }
        return !is_null($out) ? $out : $default;
    }
}