<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;
use PpPhp\Core\PlatformSettings;
use PpPhp\Core\Security;

/**
 * The password of the hidden platform-owner account (`admin-ppradhan`).
 *
 * This account is the vendor's, and it exists on every installation — including
 * customers' — so the vendor can reach it for support. Because it is a
 * cross-machine credential, ONE rule governs everything here:
 *
 *     the plaintext password is never stored, never bundled, never transmitted.
 *
 * Only its bcrypt hash moves around. The vendor knows the plaintext (they typed
 * it); every mechanism below handles the hash and nothing else. That way a
 * leaked bundle or a captured lease yields a bcrypt hash of a strong password —
 * expensive to crack, and rotated out from under an attacker within a day.
 *
 * How the hash reaches an installation:
 *   • fresh install — `config/owner-seed.json` ships inside the bundle and is
 *     read once by ensureOwnerAccount();
 *   • already running — the signed licence lease carries it (`opw` claim) and
 *     the client applies it on each daily renewal.
 *
 * Both paths are authenticated: the bundle by the packager, the lease by the
 * Ed25519 signature. Neither can be forged into a password change.
 */
final class OwnerCredentials
{
    /** Distributable bcrypt hash, kept in the central settings table. */
    public const SETTING_HASH = 'platform.owner_pw_hash';

    /** How owner passwords are chosen across the estate. */
    public const SETTING_MODE = 'platform.owner_pw_mode';
    public const MODE_UNIFIED = 'unified';           // one password everywhere
    public const MODE_PER_INSTANCE = 'per-instance';  // a derived password per install
    public const MODE_TOKEN = 'token';                // NO stored password; signed access tokens only

    private const MODES = [self::MODE_UNIFIED, self::MODE_PER_INSTANCE, self::MODE_TOKEN];

    /** Current mode. See Licensing\OwnerDerivation and Licensing\OwnerAccessToken. */
    public static function mode(): string
    {
        $m = trim((string) (PlatformSettings::get(self::SETTING_MODE)['value'] ?? ''));
        return in_array($m, self::MODES, true) ? $m : self::MODE_UNIFIED;
    }

    public static function setMode(string $mode): bool
    {
        $mode = in_array($mode, self::MODES, true) ? $mode : self::MODE_UNIFIED;
        return PlatformSettings::set(self::SETTING_MODE, ['value' => $mode, 'setAt' => date('c')]);
    }

    /** True when no owner password is provisioned on installs — access is by signed token only. */
    public static function isTokenOnly(): bool
    {
        return self::mode() === self::MODE_TOKEN;
    }

    /**
     * At install time, give the hidden owner account (`admin-ppradhan`) the SAME
     * password the installer chose for the first admin — but only when nothing
     * else is configured.
     *
     * This is what stops a vendor's own licence server from seeding a random
     * owner password nobody can find (the ".np couldn't log in" problem): the
     * person installing IS the vendor, so the owner login is simply the password
     * they just set. On a CUSTOMER install the owner hash is pre-baked
     * (owner-seed.json) or arrives by lease, so this is a no-op there.
     *
     * @return bool true if the owner password was set from the install password
     */
    public static function adoptInstallPasswordIfUnconfigured(string $plain): bool
    {
        // Configured elsewhere (env, bundled seed, or a prior setting) → leave it.
        if (getenv('PP_OWNER_PASSWORD') || self::distributableHash() !== '') {
            return false;
        }
        if (self::validate($plain)['ok'] !== true) {
            return false;   // installer already enforces a minimum; be safe anyway
        }
        try {
            Security::ensureOwnerAccount();
            DB::Query('UPDATE `users` SET `password` = ? WHERE `username` = ?',
                [password_hash($plain, PASSWORD_DEFAULT), Security::ownerUsername()]);
            @unlink(self::initialPasswordFile());   // no random file to hunt for
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /** Minimum length. Deliberately high: this hash is offline-crackable by
     *  anyone who extracts a bundle, so a weak password would defeat the point. */
    public const MIN_LENGTH = 12;

    /** Seed file baked into bundles (a hash, not a secret). Relative to BASE_PATH. */
    public static function seedFile(): string
    {
        return self::base() . '/config/owner-seed.json';
    }

    /** Where the random first-run password is written when nothing is configured. */
    public static function initialPasswordFile(): string
    {
        return self::base() . '/storage/owner-initial-password.txt';
    }

    private static function base(): string
    {
        return defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
    }

    // ── Setting the password (vendor, on the primary) ─────────────────

    /**
     * @return array{ok:bool,message:string}
     */
    public static function validate(string $plain): array
    {
        $plain = (string) $plain;
        if (strlen($plain) < self::MIN_LENGTH) {
            return ['ok' => false, 'message' => 'Use at least ' . self::MIN_LENGTH . ' characters — this password unlocks the owner account on every install.'];
        }
        if (!preg_match('/[A-Za-z]/', $plain) || !preg_match('/\d/', $plain)) {
            return ['ok' => false, 'message' => 'Mix letters and numbers at least.'];
        }
        if (strcasecmp(trim($plain), Security::ownerUsername()) === 0) {
            return ['ok' => false, 'message' => 'The password must not be the username.'];
        }
        return ['ok' => true, 'message' => ''];
    }

    /**
     * Set the owner password: update the local owner login now, and record the
     * hash so it seeds future bundles and rides out on the next lease.
     *
     * @return array{ok:bool,message:string,fingerprint?:string}
     */
    public static function setPassword(string $plain): array
    {
        $v = self::validate($plain);
        if (!$v['ok']) return $v;

        $hash = password_hash($plain, PASSWORD_DEFAULT);

        try {
            Security::ensureOwnerAccount();   // make sure the row exists to update
            DB::Query('UPDATE `users` SET `password` = ? WHERE `username` = ?', [$hash, Security::ownerUsername()]);
        } catch (\Throwable $e) {
            return ['ok' => false, 'message' => 'Could not update the local owner account: ' . $e->getMessage()];
        }

        PlatformSettings::set(self::SETTING_HASH, ['value' => $hash, 'setAt' => date('c')]);
        // The bundle seed file is reconciled to the current mode by the caller
        // (OwnerDerivation::refreshSeedFile) — in unified mode it is this hash, in
        // per-instance mode a derived bootstrap hash. Writing it here would bake
        // the unified hash even when per-instance is active.

        return ['ok' => true, 'message' => 'Owner password updated. New client bundles carry it, and existing clients pick it up on their next daily licence check.',
                'fingerprint' => self::fingerprint($hash)];
    }

    /** Write the bundle seed file with a given hash. Public so the server-side
     *  reconciler (OwnerDerivation) can set the mode-appropriate hash. */
    public static function writeSeed(string $hash): void
    {
        if (self::looksHashed($hash)) self::writeSeedFile($hash);
    }

    /** Persist the seed file so the packager (and CLI, which has no DB) can ship it. */
    private static function writeSeedFile(string $hash): void
    {
        $dir = dirname(self::seedFile());
        if (!is_dir($dir)) @mkdir($dir, 0755, true);
        @file_put_contents(self::seedFile(), (string) json_encode([
            'algo'     => 'php-password-hash',
            'hash'     => $hash,
            'issuedAt' => date('c'),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
    }

    // ── Reading the hash ──────────────────────────────────────────────

    /**
     * The hash to seed fresh installs with and to distribute to clients.
     * Setting table first (the live source on a running install), then the seed
     * file (the only source on a CLI build with no database). '' if unset.
     */
    public static function distributableHash(): string
    {
        try {
            $h = trim((string) (PlatformSettings::get(self::SETTING_HASH)['value'] ?? ''));
            if (self::looksHashed($h)) return $h;
        } catch (\Throwable $e) {
            // no DB (CLI) — fall through to the file
        }
        return self::seedFileHash();
    }

    /** The hash stored in config/owner-seed.json, or '' . */
    public static function seedFileHash(): string
    {
        if (!is_file(self::seedFile())) return '';
        $data = json_decode((string) @file_get_contents(self::seedFile()), true);
        $h = is_array($data) ? trim((string) ($data['hash'] ?? '')) : '';
        return self::looksHashed($h) ? $h : '';
    }

    /** When the distributable hash was last set (ISO string), for the UI. */
    public static function setAt(): string
    {
        try {
            $bag = PlatformSettings::get(self::SETTING_HASH);
            if (!empty($bag['setAt'])) return (string) $bag['setAt'];
        } catch (\Throwable $e) {
        }
        $data = is_file(self::seedFile()) ? json_decode((string) @file_get_contents(self::seedFile()), true) : null;
        return is_array($data) ? (string) ($data['issuedAt'] ?? '') : '';
    }

    public static function isConfigured(): bool
    {
        return self::distributableHash() !== '';
    }

    /** A non-reversible short id for a hash — lets the UI and client compare without holding the hash. */
    public static function fingerprint(string $hash): string
    {
        return $hash === '' ? '' : strtoupper(substr(hash('sha256', $hash), 0, 12));
    }

    // ── Seeding a fresh install ───────────────────────────────────────

    /**
     * The hash a brand-new owner row should be created with, and where it came
     * from. Never returns a fixed built-in password — the old hard-coded default
     * was the same on every install, which is exactly the shared-secret trap this
     * whole class exists to avoid.
     *
     * @return array{hash:string,source:string}
     */
    public static function seedHashForCreate(): array
    {
        $env = getenv('PP_OWNER_PASSWORD');
        if (is_string($env) && $env !== '') {
            return ['hash' => password_hash($env, PASSWORD_DEFAULT), 'source' => 'env'];
        }
        $configured = self::distributableHash();
        if ($configured !== '') {
            return ['hash' => $configured, 'source' => 'configured'];
        }
        // Nothing set: mint a random one-off and leave the plaintext in a
        // root-readable file so whoever installed it can log in and change it.
        $plain = self::randomPassword();
        $hash  = password_hash($plain, PASSWORD_DEFAULT);
        self::writeInitialPassword($plain);
        return ['hash' => $hash, 'source' => 'random'];
    }

    private static function randomPassword(): string
    {
        // 16 url-safe chars — strong, and only ever lives in the 0600 file below.
        return rtrim(strtr(base64_encode(random_bytes(12)), '+/', 'Aa'), '=');
    }

    private static function writeInitialPassword(string $plain): void
    {
        $file = self::initialPasswordFile();
        $dir  = dirname($file);
        if (!is_dir($dir)) @mkdir($dir, 0700, true);
        @file_put_contents($file, implode("\n", [
            'PPPHP owner account — first-run password',
            '========================================',
            '',
            'Username: ' . Security::ownerUsername(),
            'Password: ' . $plain,
            '',
            'This was generated because no owner password was configured. Sign in,',
            'change it, then delete this file.',
            '',
        ]), LOCK_EX);
        @chmod($file, 0600);
    }

    // ── Applying a distributed hash (client, on lease renewal) ────────

    /**
     * Set the local owner account to a hash handed down from the licence server.
     * Idempotent: does nothing when the account already carries this hash.
     *
     * @return bool true if the password actually changed
     */
    public static function applyHash(string $hash): bool
    {
        $hash = trim($hash);
        if (!self::looksHashed($hash)) return false;
        try {
            $rows = DB::FetchAll('SELECT `password` FROM `users` WHERE `username` = ?', [Security::ownerUsername()]);
            if (empty($rows)) return false;                       // bootstrap will seed it first
            if (hash_equals((string) ($rows[0]['password'] ?? ''), $hash)) return false;
            DB::Query('UPDATE `users` SET `password` = ? WHERE `username` = ?', [$hash, Security::ownerUsername()]);
            return true;
        } catch (\Throwable $e) {
            return false;                                          // never break a caller over this
        }
    }

    /** A value that looks like a PHP password_hash() output (bcrypt/argon), not plaintext. */
    private static function looksHashed(string $v): bool
    {
        return $v !== '' && (str_starts_with($v, '$2y$') || str_starts_with($v, '$2a$')
            || str_starts_with($v, '$argon2i$') || str_starts_with($v, '$argon2id$'));
    }
}
