<?php
declare(strict_types=1);

namespace App\System;

use App\Database\Migrator;
use App\Database\SchemaHealthService;
use App\Security\CsrfService;
use App\Support\FileLogger;
use App\Support\ReleaseManifest;
use PDO;
use Throwable;

/**
 * One place that answers "is this installation healthy".
 *
 * The CLI doctor and the admin System page used to implement the same checks
 * twice, which meant they could disagree about the same installation. They now
 * both read this service; each renders the result its own way.
 *
 * Checks are read-only and side-effect free. Network delivery tests (sending a
 * test email or a Telegram message) deliberately live elsewhere: a diagnostics
 * page that sends mail on every render gets a domain rate-limited.
 */
final class DiagnosticService
{
    public const PASS = 'pass';
    public const WARN = 'warn';
    public const FAIL = 'fail';
    public const INFO = 'info';

    /** @param array<string,mixed> $server */
    public function __construct(
        private readonly ?PDO $pdo = null,
        private readonly array $server = [],
    ) {
    }

    /**
     * @return array{
     *     checks: list<array{id:string,value:string,state:string,detail:list<string>}>,
     *     healthy: bool
     * }
     */
    public function inspect(bool $verifyReleaseFiles = false, bool $requireReleaseManifest = false, bool $verifyInventory = false): array
    {
        $checks = [];
        foreach ($this->runtime() as $check) {
            $checks[] = $check;
        }
        foreach ($this->release($verifyReleaseFiles, $requireReleaseManifest, $verifyInventory) as $check) {
            $checks[] = $check;
        }
        foreach ($this->environment() as $check) {
            $checks[] = $check;
        }
        foreach ($this->storage() as $check) {
            $checks[] = $check;
        }
        foreach ($this->database() as $check) {
            $checks[] = $check;
        }

        $healthy = true;
        foreach ($checks as $check) {
            if ($check['state'] === self::FAIL) {
                $healthy = false;
                break;
            }
        }

        return ['checks' => $checks, 'healthy' => $healthy];
    }

    /** Convenience lookup so a renderer can pull one row without scanning. */
    public static function find(array $result, string $id): ?array
    {
        foreach ($result['checks'] ?? [] as $check) {
            if (($check['id'] ?? '') === $id) {
                return $check;
            }
        }

        return null;
    }

    /** @return list<array{id:string,value:string,state:string,detail:list<string>}> */
    private function runtime(): array
    {
        return [
            self::check('version', app_version()),
            self::check('root', app_root_path()),
            self::check('php', PHP_VERSION, version_compare(PHP_VERSION, '8.1', '>=') ? self::PASS : self::FAIL),
            self::check('pdo_mysql', extension_loaded('pdo_mysql') ? 'yes' : 'no', extension_loaded('pdo_mysql') ? self::PASS : self::FAIL),
            self::check('json', extension_loaded('json') ? 'yes' : 'no', extension_loaded('json') ? self::PASS : self::FAIL),
            self::check('mbstring', extension_loaded('mbstring') ? 'yes' : 'no', extension_loaded('mbstring') ? self::PASS : self::WARN),
            self::check('curl', extension_loaded('curl') ? 'yes' : 'no', extension_loaded('curl') ? self::PASS : self::WARN),
            self::check('openssl', extension_loaded('openssl') ? 'yes' : 'no', extension_loaded('openssl') ? self::PASS : self::WARN),
            self::check('server_software', substr((string) ($this->server['SERVER_SOFTWARE'] ?? PHP_SAPI), 0, 160)),
        ];
    }

    /** @return list<array{id:string,value:string,state:string,detail:list<string>}> */
    private function release(bool $verifyFiles, bool $requirePresent, bool $verifyInventory): array
    {
        $release = ReleaseManifest::inspect($verifyFiles, $requirePresent, $verifyInventory);
        $issues = array_map(static fn ($issue): string => (string) $issue, (array) ($release['issues'] ?? []));

        if (!(bool) ($release['present'] ?? false)) {
            $state = $requirePresent ? self::FAIL : self::INFO;
            $value = $requirePresent ? 'missing (required)' : 'development source tree';
        } else {
            $state = (bool) ($release['valid'] ?? false) ? self::PASS : self::FAIL;
            $value = (bool) ($release['valid'] ?? false) ? 'verified' : 'invalid';
        }

        return [
            self::check('build_id', (string) ($release['build_id'] ?? '')),
            self::check('built_at', (string) ($release['built_at'] ?? '')),
            self::check('release_manifest', $value, $state, $issues),
        ];
    }

    /** @return list<array{id:string,value:string,state:string,detail:list<string>}> */
    private function environment(): array
    {
        $base = app_resolve_base_path((string) app_env('APP_BASE_URL', ''), $this->server);
        $candidates = [];
        foreach ((array) ($base['candidates'] ?? []) as $source => $candidate) {
            $candidates[] = $source . '=' . ($candidate === '' ? '/' : $candidate);
        }
        $ambiguous = (bool) ($base['ambiguous'] ?? false);
        $https = CsrfService::isHttps();

        return [
            self::check('base_path', app_base_path() === '' ? '/' : app_base_path()),
            self::check('base_source', (string) ($base['source'] ?? '')),
            self::check('base_candidates', $candidates === [] ? 'none' : implode('; ', $candidates)),
            self::check('base_ambiguous', $ambiguous ? 'yes' : 'no', $ambiguous ? self::WARN : self::PASS),
            self::check('admin_path', '/' . quaeriva_admin_path()),
            self::check('https', $https ? 'yes' : 'no', $https ? self::PASS : self::WARN),
            self::check('app_env', (string) app_env('APP_ENV', '')),
        ];
    }

    /** @return list<array{id:string,value:string,state:string,detail:list<string>}> */
    private function storage(): array
    {
        $log = (new FileLogger())->diagnostics();
        $writable = (bool) ($log['directory_writable'] ?? false) && (bool) ($log['file_writable'] ?? false);

        // LiteSpeed and suPHP refuse to execute a script writable by group or
        // other and answer 403 with nothing in the PHP log, so the operator
        // cannot tell this apart from a routing fault without being told.
        // Windows has no Unix mode -- PHP reports 0666/0777 for every file --
        // so the check would report a problem that cannot exist there.
        $loose = [];
        $windows = DIRECTORY_SEPARATOR === '\\';
        foreach ($windows ? [] : ['public/index.php', 'admin/index.php', 'install/index.php', '.htaccess', 'config'] as $relative) {
            $path = app_root_path(...explode('/', $relative));
            $mode = file_exists($path) ? fileperms($path) : false;
            if ($mode !== false && ($mode & 0022) !== 0) {
                $loose[] = $relative . ' ' . decoct($mode & 0777);
            }
        }

        $localDbConfig = app_root_path('config', 'database.local.php');

        return [
            self::check('log_path', (string) ($log['path'] ?? '')),
            self::check('log_writable', $writable ? 'yes' : 'no', $writable ? self::PASS : self::FAIL),
            self::check('session_handler', (string) ini_get('session.save_handler')),
            self::check('session_path', (string) session_save_path()),
            self::check(
                'file_permissions',
                $windows ? 'not applicable on Windows' : ($loose === [] ? 'ok' : 'loose'),
                $windows || $loose === [] ? self::PASS : self::FAIL,
                $loose
            ),
            self::check('database_config_source', is_file($localDbConfig) ? 'config/database.local.php' : 'environment/defaults'),
        ];
    }

    /** @return list<array{id:string,value:string,state:string,detail:list<string>}> */
    private function database(): array
    {
        if ($this->pdo === null) {
            return $this->databaseUnavailable();
        }

        try {
            $name = (string) $this->pdo->query('SELECT DATABASE()')->fetchColumn();
            $version = (string) $this->pdo->query('SELECT VERSION()')->fetchColumn();
            $migrationsPath = app_root_path('database', 'migrations');
            $applied = (new Migrator($this->pdo, $migrationsPath))->applied();
            $health = (new SchemaHealthService($this->pdo, $migrationsPath))->inspect();
            $pending = array_map(static fn ($file): string => (string) $file, (array) ($health['pending'] ?? []));
            $issues = array_map(static fn ($issue): string => (string) $issue, (array) ($health['issues'] ?? []));

            $layouts = (int) $this->pdo->query("SELECT COUNT(*) FROM layouts WHERE status = 'published'")->fetchColumn();
            $sections = (int) $this->pdo->query('SELECT COUNT(*) FROM layout_sections')->fetchColumn();
            $widgets = (int) $this->pdo->query('SELECT COUNT(*) FROM widgets')->fetchColumn();
            $layoutUsable = $layouts > 0 && $sections > 0;

            return [
                self::check('database', $name, $name !== '' ? self::PASS : self::FAIL),
                self::check('database_version', $version),
                self::check('migrations_applied', (string) count($applied)),
                self::check('migrations_pending', $pending === [] ? 'none' : (string) count($pending), $pending === [] ? self::PASS : self::WARN, $pending),
                self::check('schema_integrity', $issues === [] ? 'ok' : 'failed', $issues === [] ? self::PASS : self::FAIL, $issues),
                self::check('layout', $layouts . ' / ' . $sections . ' / ' . $widgets, $layoutUsable ? self::PASS : self::FAIL),
            ];
        } catch (Throwable $throwable) {
            // The message can name a host or a user, so it stays out of the
            // rendered value and is reported as a detail line only.
            return $this->databaseUnavailable($throwable->getMessage());
        }
    }

    /**
     * The schema rows are emitted even when the database is unreachable, so a
     * renderer never has to handle a shorter list than it was written against.
     *
     * @return list<array{id:string,value:string,state:string,detail:list<string>}>
     */
    private function databaseUnavailable(string $reason = ''): array
    {
        $detail = $reason === '' ? [] : [$reason];

        return [
            self::check('database', 'unavailable', self::FAIL, $detail),
            self::check('database_version', 'unknown', self::FAIL),
            self::check('migrations_applied', 'unknown', self::FAIL),
            self::check('migrations_pending', 'unknown', self::FAIL),
            self::check('schema_integrity', 'unknown', self::FAIL),
            self::check('layout', 'unknown', self::FAIL),
        ];
    }

    /** @param list<string> $detail */
    private static function check(string $id, string $value, string $state = self::INFO, array $detail = []): array
    {
        return ['id' => $id, 'value' => $value, 'state' => $state, 'detail' => array_values($detail)];
    }
}
