<?php
/**
 * PasswordPolicy — single source of truth for password strength rules.
 *
 * Every place a user can set or change a password (Sign Up, Reset
 * Password, Change Password in Settings) must call validate() here
 * server-side before hashing anything. Client-side hints exist purely
 * for UX — they are never trusted on their own, since a request can
 * always bypass the browser.
 *
 * User-facing copy from this class describes requirements only, never
 * implementation (hashing algorithm, storage mechanism, etc.) — that
 * information belongs in engineering docs/comments, not in the UI.
 */
class PasswordPolicy
{
    public const MIN_LENGTH = 8;
    public const MAX_LENGTH = 128;

    /**
     * A floor, not a full breached-password database (that would need a
     * network call, e.g. HaveIBeenPwned's k-anonymity API, which this app
     * doesn't currently integrate). Catches the passwords/patterns most
     * likely to appear in a naive/lazy attempt to satisfy the character
     * rules above without picking anything actually unpredictable.
     */
    private const COMMON_PASSWORDS = [
        'password', 'password1', 'password123', 'password1!', 'password!23',
        '12345678', '123456789', '1234567890', 'qwerty123', 'qwertyuiop',
        'qwerty1234', 'admin123', 'admin1234', 'letmein123', 'letmein1234',
        'welcome123', 'welcome1234', 'iloveyou1', 'iloveyou123', 'trustno1',
        'abc123456', 'abcd1234', 'passw0rd', 'passw0rd1', 'p@ssw0rd',
        'p@ssword1', 'football123', 'baseball123', 'dragon1234', 'monkey123',
        'master1234', 'sunshine123', 'princess123', 'starwars123', 'shadow123',
        'michael123', 'jennifer123', 'superman123', 'freedom123', 'whatever123',
        'trustme01!', 'changeme123', 'default123', 'temppass123', 'newpassword',
        'testpass123', 'letmein12', '1q2w3e4r5t', 'zaq1zaq123', '1qaz2wsx3edc',
        'Password1', 'Password123', 'Password1!', 'Welcome123', 'Welcome123!',
        'Qwerty123', 'Qwerty123!', 'traderalign', 'trading1234', 'trader1234',
        'chartpattern1', 'stockmarket1',
    ];

    /**
     * @return string[] Empty array = password is acceptable. Otherwise one
     *                   short imperative sentence per unmet requirement.
     * @param array $context Optional: ['email' => string] to reject a
     *                        password matching the account's own email.
     */
    public static function validate(string $password, array $context = []): array
    {
        $errors = [];

        if (mb_strlen($password) < self::MIN_LENGTH) {
            $errors[] = 'Password must be at least ' . self::MIN_LENGTH . ' characters long.';
        }
        if (mb_strlen($password) > self::MAX_LENGTH) {
            $errors[] = 'Password must be no more than ' . self::MAX_LENGTH . ' characters long.';
        }
        if (!preg_match('/[A-Z]/u', $password)) {
            $errors[] = 'Password must include at least one uppercase letter.';
        }
        if (!preg_match('/[a-z]/u', $password)) {
            $errors[] = 'Password must include at least one lowercase letter.';
        }
        if (!preg_match('/[0-9]/', $password)) {
            $errors[] = 'Password must include at least one number.';
        }
        if (!preg_match('/[^A-Za-z0-9]/u', $password)) {
            $errors[] = 'Password must include at least one symbol (e.g. ! @ # $ %).';
        }

        // A single repeated character, e.g. "aaaaaaaaaa".
        if (preg_match('/^(.)\1+$/u', $password)) {
            $errors[] = 'Password must not be a single repeated character.';
        }

        // Simple ascending/descending runs, e.g. "abcdefgh" or "87654321".
        if (self::isSequential($password)) {
            $errors[] = 'Password must not be a simple sequential pattern.';
        }

        $normalized = mb_strtolower(trim($password));
        foreach (self::COMMON_PASSWORDS as $common) {
            if ($normalized === mb_strtolower($common)) {
                $errors[] = 'Password is too common. Please choose a less predictable one.';
                break;
            }
        }

        $email = mb_strtolower(trim((string)($context['email'] ?? '')));
        if ($email !== '') {
            $localPart = explode('@', $email)[0];
            if ($normalized === $email || ($localPart !== '' && $normalized === $localPart)) {
                $errors[] = 'Password must not be the same as your email address.';
            }
        }

        return $errors;
    }

    public static function isValid(string $password, array $context = []): bool
    {
        return empty(self::validate($password, $context));
    }

    private static function isSequential(string $password): bool
    {
        $s = mb_strtolower($password);
        $len = mb_strlen($s);
        if ($len < 6) {
            return false;
        }
        $ascRun = 1;
        $descRun = 1;
        $maxRun = 1;
        for ($i = 1; $i < $len; $i++) {
            $prev = mb_substr($s, $i - 1, 1);
            $cur = mb_substr($s, $i, 1);
            if (mb_strlen($prev) !== 1 || mb_strlen($cur) !== 1) {
                continue;
            }
            $prevOrd = mb_ord($prev);
            $curOrd = mb_ord($cur);
            if ($curOrd === $prevOrd + 1) {
                $ascRun++;
                $descRun = 1;
            } elseif ($curOrd === $prevOrd - 1) {
                $descRun++;
                $ascRun = 1;
            } else {
                $ascRun = 1;
                $descRun = 1;
            }
            $maxRun = max($maxRun, $ascRun, $descRun);
        }
        return $maxRun >= 6;
    }

    /** One-line, plain-language summary — safe to show on every password form. */
    public static function requirementsSummary(): string
    {
        return 'At least ' . self::MIN_LENGTH . ' characters, with uppercase, lowercase, a number, and a symbol.';
    }

    /** Bullet-checklist labels, same wording used everywhere a password is set. */
    public static function requirementsList(): array
    {
        return [
            'length' => 'At least ' . self::MIN_LENGTH . ' characters',
            'upper' => 'One uppercase letter',
            'lower' => 'One lowercase letter',
            'number' => 'One number',
            'symbol' => 'One symbol (e.g. ! @ # $ %)',
        ];
    }

    /**
     * password_hash()/password_needs_rehash() options — one place, used by
     * every entry point that hashes or re-hashes a password, so cost
     * parameters can never quietly drift between Sign Up, Reset Password,
     * Change Password, and Auth's own opportunistic rehash-on-login.
     */
    public static function hashOptions(): array
    {
        return ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1];
    }
}
