<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

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

/**
 * Establishing the hidden platform owner is gated behind proof of control of the
 * canonical owner mailbox — NOT a well-known username.
 *
 * The old behaviour (Security::ensureOwnerAccount auto-promoting any account
 * named `admin-ppradhan` to the owner role) was a privilege-escalation: anyone
 * could type that username during install and become owner. Now:
 *
 *   • The owner role is granted only when `owner_verified` is set.
 *   • That flag is set either by GRANDFATHERING (a legit install that already has
 *     an owner-role account keeps it — nobody is locked out) or by passing this
 *     email-code challenge: a 6-digit code is sent to the fixed owner email
 *     (ppradhan@live.com), and entering it establishes the owner.
 *   • The username + email are reserved everywhere else (installer, user admin).
 *
 * The code is delivered over THREE channels so the owner can always be established,
 * even before any email is configured:
 *   1. SMTP (configured or bundled, via primeMailer),
 *   2. the host's native mail() as a fallback,
 *   3. a server-only FILE (storage/owner-setup/verification-code.txt) — web-denied
 *      and 0600, so only whoever controls the hosting account (via cPanel File
 *      Manager / FTP / SSH) can read it. That IS the proof of ownership, which makes
 *      this 100% reliable and safe with no mail at all. Single-use; cleared on verify
 *      or expiry. The code is never logged or returned to the browser (the file PATH
 *      is safe to show — reading it still requires hosting-account access).
 */
final class OwnerVerification
{
    private const FLAG        = 'platform.owner_verified';
    private const CHALLENGE   = 'platform.owner_verify_challenge';
    private const CODE_TTL    = 900;   // a code is valid 15 minutes
    private const COOLDOWN    = 60;    // min seconds between sends
    private const MAX_ATTEMPTS = 5;
    /** Email-free fallback: the code is also written here (web-denied, server-only). */
    private const CODE_FILE   = 'storage/owner-setup/verification-code.txt';

    /** Where the file fallback lives, relative to the install root (for the UI/message). */
    public static function codeFileRelPath(): string
    {
        return self::CODE_FILE;
    }

    private static function codeFileAbs(): string
    {
        $root = defined('BASE_PATH') ? (string) constant('BASE_PATH') : dirname(__DIR__, 2);
        return rtrim($root, '/\\') . '/' . self::CODE_FILE;
    }

    /**
     * Write the code to a server-only file — the email-free delivery channel that
     * works on EVERY host. Whoever can read this file (via cPanel File Manager / FTP /
     * SSH) controls the hosting account and is, by definition, the legitimate owner.
     * The directory is denied to the web (defence in depth; it already sits under
     * storage/). Single-use: cleared on verify or expiry. @return bool written
     */
    private static function writeCodeFile(string $code): bool
    {
        try {
            $abs = self::codeFileAbs();
            $dir = dirname($abs);
            if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { return false; }
            $ht = $dir . '/.htaccess';
            if (!is_file($ht)) {
                @file_put_contents($ht, "# PPPHP owner setup — deny all web access\n"
                    . "<IfModule mod_authz_core.c>\n  Require all denied\n</IfModule>\n"
                    . "<IfModule !mod_authz_core.c>\n  Order allow,deny\n  Deny from all\n</IfModule>\n");
            }
            $when = gmdate('Y-m-d H:i:s') . ' UTC';
            $body = "PPPHP — PLATFORM OWNER VERIFICATION CODE\n"
                  . "=======================================\n\n"
                  . "    Verification code:   {$code}\n\n"
                  . "Generated {$when} — valid for 15 minutes.\n\n"
                  . "Enter this code on the \"Establish platform owner\" page to finish setup.\n"
                  . "You are seeing this file because email is not required: being able to read\n"
                  . "it here proves you control this server, which is what makes you the owner.\n"
                  . "It is single-use and is removed automatically once the code is used or\n"
                  . "expires. Delete it yourself if you cancel setup.\n";
            $ok = @file_put_contents($abs, $body) !== false;
            if ($ok) { @chmod($abs, 0600); }
            return $ok;
        } catch (\Throwable $e) {
            return false;
        }
    }

    /** Remove the fallback code file (called on successful verify or expiry). */
    public static function clearCodeFile(): void
    {
        try { $f = self::codeFileAbs(); if (is_file($f)) { @unlink($f); } } catch (\Throwable $e) { /* best effort */ }
    }

    /** The canonical owner email — env-overridable, never taken from user input. */
    public static function ownerEmail(): string
    {
        $e = getenv('PP_OWNER_EMAIL');
        return ($e !== false && $e !== '') ? $e : PlatformSettings::DEFAULT_OWNER_EMAIL;
    }

    public static function isVerified(): bool
    {
        try {
            return (string) (PlatformSettings::get(self::FLAG)['value'] ?? '') === '1';
        } catch (\Throwable $e) {
            return false;
        }
    }

    private static function markVerified(): void
    {
        PlatformSettings::set(self::FLAG, ['value' => '1', 'at' => gmdate('c')]);
    }

    /**
     * Grandfather: an install that ALREADY has an owner-role account is legit
     * (e.g. the vendor's own machines, or any deployment that predates this
     * change) — mark it verified so nothing breaks and no one is locked out.
     * Idempotent + best-effort; called from Security::ensureOwnerAccount().
     */
    public static function ensureGrandfathered(): void
    {
        if (self::isVerified()) return;
        try {
            if (UserSchema::col('role') === null) return;
            $n = (int) DB::FetchOne(
                'SELECT COUNT(*) FROM ' . UserSchema::qTable() . ' WHERE ' . UserSchema::qCol('role') . ' = ?',
                ['owner']
            );
            if ($n > 0) self::markVerified();
        } catch (\Throwable $e) {
            // no table yet / non-owner schema — nothing to grandfather
        }
    }

    /** True when a code has been sent and not yet expired. */
    public static function challengePending(): bool
    {
        try {
            $c = PlatformSettings::get(self::CHALLENGE);
            return !empty($c['exp']) && (int) $c['exp'] > time();
        } catch (\Throwable $e) {
            return false;
        }
    }

    /**
     * Send a fresh 6-digit code to the canonical owner mailbox. Rate-limited.
     * Never returns or logs the code. @return array{success:bool,message:string}
     */
    public static function sendChallenge(): array
    {
        if (self::isVerified()) {
            return ['success' => false, 'message' => 'The platform owner is already established on this install.'];
        }
        $c = PlatformSettings::get(self::CHALLENGE);
        if (!empty($c['sent']) && (time() - (int) $c['sent']) < self::COOLDOWN) {
            return ['success' => false, 'message' => 'Please wait a moment before requesting another code.'];
        }

        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        PlatformSettings::set(self::CHALLENGE, [
            'hash'     => password_hash($code, PASSWORD_BCRYPT),
            'exp'      => time() + self::CODE_TTL,
            'sent'     => time(),
            'attempts' => 0,
        ]);

        // Two independent delivery channels. The FILE always works (no email needed)
        // and is what makes the owner establishable before mail is configured; email is
        // a convenience on top. Only a total failure of BOTH is an error.
        $fileOk  = self::writeCodeFile($code);
        $emailed = self::deliver(self::ownerEmail(), $code);

        if (!$emailed && !$fileOk) {
            return ['success' => false, 'message' => 'Could not email the code, and could not write it to the server either. Make sure storage/owner-setup/ is writable, then try again.'];
        }

        // The email ADDRESS is never echoed back (no hint to a would-be claimant); the
        // file PATH is safe to show — reading it still requires hosting-account access.
        $path = self::codeFileRelPath();
        $msg = $emailed
            ? ('A 6-digit code was sent to the platform owner email (valid 15 minutes). If it does not arrive, the SAME code is saved on your server at ' . $path . ' — open it with cPanel File Manager, FTP or SSH.')
            : ('Email is not set up yet, so your 6-digit code was saved on your server at ' . $path . ' (valid 15 minutes). Open that file with cPanel File Manager, FTP or SSH, then paste the code below — email is not required to establish the owner.');
        return ['success' => true, 'message' => $msg, 'emailed' => $emailed, 'code_file' => $path];
    }

    /**
     * Verify a code and, on success, establish the owner: create/promote the
     * reserved owner account (email pinned) with the supplied password.
     * @return array{success:bool,message:string}
     */
    public static function verify(string $code, string $password): array
    {
        if (self::isVerified()) {
            return ['success' => false, 'message' => 'The platform owner is already established.'];
        }
        $c = PlatformSettings::get(self::CHALLENGE);
        if (empty($c['hash']) || empty($c['exp']) || (int) $c['exp'] < time()) {
            self::clearCodeFile();  // expired — don't leave a stale code on disk
            return ['success' => false, 'message' => 'The code has expired. Request a new one.'];
        }
        if ((int) ($c['attempts'] ?? 0) >= self::MAX_ATTEMPTS) {
            return ['success' => false, 'message' => 'Too many incorrect attempts. Request a new code.'];
        }
        if (!password_verify(trim($code), (string) $c['hash'])) {
            $c['attempts'] = (int) ($c['attempts'] ?? 0) + 1;
            PlatformSettings::set(self::CHALLENGE, $c);
            return ['success' => false, 'message' => 'That code is incorrect.'];
        }
        if (strlen($password) < 10) {
            return ['success' => false, 'message' => 'Choose an owner password of at least 10 characters.'];
        }

        self::markVerified();
        PlatformSettings::set(self::CHALLENGE, []); // consume the challenge
        self::clearCodeFile();                      // single-use: remove the fallback file

        try {
            // Now that it's verified, let the canonical seeding path create/promote
            // the owner (widens the role enum, pins the email), then set the
            // vendor's chosen password. force=true bypasses the once-per-request guard.
            Security::ensureOwnerAccount(true);
            $uT = UserSchema::qTable();
            DB::Query(
                "UPDATE {$uT} SET " . UserSchema::qCol('password') . ' = ? WHERE ' . UserSchema::qCol('username') . ' = ?',
                [Security::HashPassword($password), Security::ownerUsername()]
            );
        } catch (\Throwable $e) {
            return ['success' => false, 'message' => 'Verified, but establishing the owner account failed: ' . $e->getMessage()];
        }

        return ['success' => true, 'message' => 'Platform owner established. Sign in as “' . Security::ownerUsername() . '”.'];
    }

    /**
     * Deliver the code. SMTP first (configured or bundled, via primeMailer), then
     * the host's native mail() — so this works even with no email set up. Mirrors
     * OwnerRecovery's transport. Never surfaces the code anywhere else.
     */
    private static function deliver(string $to, string $code): bool
    {
        $subject = 'PPPHP platform owner verification code';
        $html = '<div style="font-family:system-ui,Segoe UI,Arial,sans-serif;max-width:520px;margin:auto">'
              . '<h2 style="margin:0 0 8px">Platform owner verification</h2>'
              . '<p>Use this code to establish the platform owner account on a PPPHP install:</p>'
              . '<p style="font-size:30px;font-weight:800;letter-spacing:6px;margin:18px 0">' . $code . '</p>'
              . '<p style="color:#555">It expires in 15 minutes. If you did not start this, you can ignore this email — no change is made without the code.</p>'
              . '</div>';
        $text = "PPPHP platform owner verification code: {$code}\nExpires in 15 minutes. Ignore this email if you didn't request it.";

        try {
            if (PlatformSettings::primeMailer()) {
                $r = Email::send(['to' => $to, 'subject' => $subject, 'htmlbody' => $html, 'body' => $text]);
                if (!empty($r['mailed'])) return true;
            }
        } catch (\Throwable $e) {
            // fall through to native mail()
        }

        if (!function_exists('mail')) return false;
        $host = preg_replace('/:\d+$/', '', (string) ($_SERVER['HTTP_HOST'] ?? ''));
        if ($host === '' || strpos($host, '.') === false) $host = 'localhost.localdomain';
        $from = 'no-reply@' . $host;
        $headers = "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n"
                 . 'From: PPPHP <' . $from . ">\r\nReply-To: " . $from . "\r\n";
        try {
            return @mail($to, $subject, $html, $headers);
        } catch (\Throwable $e) {
            return false;
        }
    }
}
