<?php

declare(strict_types=1);

/**
 * DentalCare v0.9 hardening foundation.
 *
 * The functions in this file deliberately avoid framework state where possible
 * so they can be exercised by PHPUnit, the zero-dependency test runner, CI,
 * and the in-application Hardening Center.
 */

function dental_hardening_role_combination_result(array $roles): array
{
    $roles = array_values(array_unique(array_filter(array_map(
        static fn(mixed $role): string => strtolower(trim((string)$role)),
        $roles
    ))));
    sort($roles);

    // Superuser and the combined General Staff account remain dedicated. A
    // dedicated Cashier role may be assigned alongside Receptionist or another
    // compatible role because v0.9.1.0 switches one server-side Task View at a
    // time and clears patient/task context between them.
    $dedicated = ['super_admin', 'staff'];
    foreach ($dedicated as $role) {
        if (in_array($role, $roles, true) && count($roles) > 1) {
            return [
                'allowed' => false,
                'reason' => $role . ' is a dedicated account type and cannot be merged with another operational role.',
                'roles' => $roles,
            ];
        }
    }

    $blockedPairs = [
        ['dentist', 'dental_assistant'],
        ['dentist', 'inventory_officer'],
        ['dental_assistant', 'inventory_officer'],
        ['cashier', 'clinic_admin'],
    ];
    foreach ($blockedPairs as [$left, $right]) {
        if (in_array($left, $roles, true) && in_array($right, $roles, true)) {
            return [
                'allowed' => false,
                'reason' => $left . ' and ' . $right . ' must remain separate for clinical or financial accountability.',
                'roles' => $roles,
            ];
        }
    }

    return ['allowed' => true, 'reason' => '', 'roles' => $roles];
}

function dental_hardening_patient_ledger_status_visible(string $encounterStatus, string $invoiceStatus = ''): bool
{
    $encounterStatus = strtolower(trim($encounterStatus));
    $invoiceStatus = strtolower(trim($invoiceStatus));
    if (in_array($encounterStatus, ['draft', 'open', 'in_progress', 'void', 'deleted'], true)) return false;
    if (in_array($invoiceStatus, ['draft', 'void', 'deleted'], true)) return false;
    return in_array($encounterStatus, ['finalized', 'completed', 'closed', 'discharged'], true)
        || in_array($invoiceStatus, ['issued', 'partial', 'paid', 'closed'], true);
}

function dental_hardening_release_path_allowed(string $path): bool
{
    $path = str_replace('\\', '/', trim($path));
    $path = ltrim(preg_replace('#/+#', '/', $path) ?? $path, '/');
    if ($path === '' || str_contains($path, '../')) return false;

    $blockedExact = [
        'config/generated.php',
        'storage/installed.lock',
        '.env',
        '.env.local',
    ];
    if (in_array($path, $blockedExact, true)) return false;

    $blockedPrefixes = [
        'storage/runtime/',
        'storage/backups/',
        'storage/private/',
        'storage/generated-installers/',
        'storage/cache/',
        'public/uploads/',
        'vendor/',
        'node_modules/',
    ];
    foreach ($blockedPrefixes as $prefix) {
        if (str_starts_with($path, $prefix)) return false;
    }

    $blockedSuffixes = ['.sql.gz', '.bak', '.dump', '.pem.key', '.session'];
    foreach ($blockedSuffixes as $suffix) {
        if (str_ends_with(strtolower($path), $suffix)) return false;
    }

    return true;
}

function dental_hardening_validate_enum(string $field, mixed $value, array $allowed): string
{
    $normalized = strtolower(trim((string)$value));
    $allowedNormalized = array_values(array_unique(array_map(
        static fn(mixed $item): string => strtolower(trim((string)$item)),
        $allowed
    )));
    if ($normalized === '' || !in_array($normalized, $allowedNormalized, true)) {
        throw new InvalidArgumentException($field . ' contains an unsupported value.');
    }
    return $normalized;
}

function dental_hardening_migration_checksum_is_officially_compatible(string $migrationKey, string $storedChecksum, string $packageChecksum): bool
{
    $storedChecksum = strtolower(trim($storedChecksum));
    $packageChecksum = strtolower(trim($packageChecksum));
    if ($storedChecksum === '' || $packageChecksum === '') return false;
    if (hash_equals($storedChecksum, $packageChecksum)) return true;

    $officialAliases = [
        '0.9.1.2_patient_portal_controlled_record_release' => [
            '8001a2f35a730f7cbd622d9839e1e6cdc2f16fa61a77b71ee9c34917eb301fd2',
            'ea5a80c731c4ac8793a29c546ded867373ba3a9010f29147b1527137b7eb9ad2',
        ],
    ];
    $allowed = $officialAliases[$migrationKey] ?? [];
    return in_array($storedChecksum, $allowed, true)
        && in_array($packageChecksum, $allowed, true);
}

function dental_hardening_migration_plan(array $packageMigrations, array $ledgerRows): array
{
    $completed = [];
    foreach ($ledgerRows as $row) {
        if (!is_array($row)) continue;
        $key = trim((string)($row['migration_key'] ?? ''));
        if ($key === '' || strtolower((string)($row['status'] ?? '')) !== 'completed') continue;
        $completed[$key] = strtolower(trim((string)($row['checksum_sha256'] ?? '')));
    }

    $pending = [];
    foreach ($packageMigrations as $migration) {
        if (!is_array($migration)) continue;
        $key = trim((string)($migration['migration_key'] ?? ''));
        $checksum = strtolower(trim((string)($migration['checksum_sha256'] ?? '')));
        if ($key === '') throw new InvalidArgumentException('Every migration requires a migration_key.');
        if ($checksum !== '' && !preg_match('/^[a-f0-9]{64}$/', $checksum)) {
            throw new InvalidArgumentException('Migration ' . $key . ' has an invalid SHA-256 checksum.');
        }
        if (isset($completed[$key])) {
            if ($checksum !== '' && $completed[$key] !== ''
                && !dental_hardening_migration_checksum_is_officially_compatible($key, $completed[$key], $checksum)) {
                throw new RuntimeException('Completed migration checksum mismatch for ' . $key . '.');
            }
            continue;
        }
        $pending[] = $migration;
    }
    return $pending;
}

function dental_hardening_release_manifest(?string $root = null): array
{
    $root = $root ?: (defined('DENTAL_ROOT') ? DENTAL_ROOT : dirname(__DIR__));
    $root = rtrim($root, '/');
    $version = function_exists('app_release_version')
        ? app_release_version()
        : trim((string)@file_get_contents($root . '/VERSION'));

    // The machine-readable release manifest is application state, not user
    // documentation. Keep it under config so removing handbooks or release
    // notes cannot make an already-completed application look update-pending.
    $candidates = [
        $root . '/config/release_manifest.json',
        $root . '/RELEASE_MANIFEST.json',
        $root . '/docs/UPDATE_MANIFEST_v' . $version . '.json', // legacy fallback
    ];
    foreach ($candidates as $path) {
        if (!is_file($path) || !is_readable($path)) continue;
        $decoded = json_decode((string)file_get_contents($path), true);
        if (!is_array($decoded)) continue;
        $manifestRelease = trim((string)($decoded['release'] ?? ''));
        if ($manifestRelease !== '' && $version !== '' && $manifestRelease !== $version) continue;
        return $decoded;
    }
    return [];
}

function dental_hardening_required_files(): array
{
    $required = [
        'composer.json' => 'Composer development dependency manifest',
        'phpunit.xml.dist' => 'PHPUnit test configuration',
        'phpstan.neon.dist' => 'PHPStan static-analysis configuration',
        '.semgrep.yml' => 'Semgrep security rules',
        'tests/bootstrap.php' => 'Test bootstrap',
        'tests/standalone_runner.php' => 'Zero-dependency policy tests',
        'tools/run_hardening_checks.php' => 'Zero-dependency hardening runner',
    ];
    $ownerSource = !function_exists('dental_distribution_feature_allowed')
        || dental_distribution_feature_allowed('package_builder');
    if ($ownerSource) {
        $required['.github/workflows/dentalcare-ci.yml'] = 'Continuous-integration workflow';
        $required['tools/build_release.php'] = 'Deterministic release-package builder';
    }
    return $required;
}

function dental_hardening_source_files(string $root): array
{
    $result = [];
    foreach (['app', 'modules', 'portal', 'handlers', 'config', 'tools'] as $directory) {
        $base = rtrim($root, '/') . '/' . $directory;
        if (!is_dir($base)) continue;
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
        foreach ($iterator as $file) {
            if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') continue;
            $path = str_replace('\\', '/', $file->getPathname());
            if (str_contains($path, '/vendor/')) continue;
            $result[] = $path;
        }
    }
    sort($result);
    return $result;
}

function dental_hardening_source_scan(string $root): array
{
    $files = dental_hardening_source_files($root);
    $strict = 0;
    $prohibited = [];
    $directSqlInput = [];
    $largeFiles = [];
    $prohibitedNames = ['eval', 'shell_exec', 'passthru', 'popen', 'pcntl_exec', 'unserialize'];

    foreach ($files as $file) {
        $source = (string)file_get_contents($file);
        $relative = ltrim(str_replace(str_replace('\\', '/', rtrim($root, '/')), '', str_replace('\\', '/', $file)), '/');
        if (preg_match('/declare\s*\(\s*strict_types\s*=\s*1\s*\)/', $source)) $strict++;
        if (substr_count($source, "\n") + 1 > 4000) $largeFiles[] = $relative;

        $tokens = token_get_all($source);
        $count = count($tokens);
        for ($index = 0; $index < $count; $index++) {
            $token = $tokens[$index];
            if (!is_array($token) || $token[0] !== T_STRING) continue;
            $name = strtolower($token[1]);
            if (!in_array($name, $prohibitedNames, true)) continue;
            $previous = $index > 0 ? $tokens[$index - 1] : null;
            if (is_array($previous) && $previous[0] === T_FUNCTION) continue;
            $prohibited[] = $relative . ':' . (int)$token[2] . ' ' . $name . '()';
        }

        if (preg_match_all('/(?:->query|->exec)\s*\([^;\n]*(?:\$_GET|\$_POST|\$_REQUEST)/i', $source, $matches, PREG_OFFSET_CAPTURE)) {
            foreach ($matches[0] as [$match, $offset]) {
                $line = substr_count(substr($source, 0, (int)$offset), "\n") + 1;
                $directSqlInput[] = $relative . ':' . $line . ' ' . trim((string)$match);
            }
        }
    }

    $testFiles = [];
    $testsRoot = rtrim($root, '/') . '/tests';
    if (is_dir($testsRoot)) {
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($testsRoot, FilesystemIterator::SKIP_DOTS));
        foreach ($iterator as $file) {
            if ($file->isFile() && strtolower($file->getExtension()) === 'php') {
                $testFiles[] = str_replace('\\', '/', substr($file->getPathname(), strlen(rtrim($root, '/')) + 1));
            }
        }
    }

    $missingRequired = [];
    foreach (dental_hardening_required_files() as $path => $label) {
        if (!is_file(rtrim($root, '/') . '/' . $path)) $missingRequired[$path] = $label;
    }

    return [
        'php_files' => count($files),
        'strict_types_files' => $strict,
        'strict_types_percent' => count($files) > 0 ? round(($strict / count($files)) * 100, 1) : 0.0,
        'test_files' => $testFiles,
        'test_file_count' => count($testFiles),
        'prohibited_calls' => array_values(array_unique($prohibited)),
        'direct_sql_input_patterns' => array_values(array_unique($directSqlInput)),
        'large_files' => array_values(array_unique($largeFiles)),
        'missing_required_files' => $missingRequired,
    ];
}

function dental_hardening_database_checks(PDO $pdo): array
{
    $checks = [];
    $requiredTables = [
        'system_migration_ledger',
        'system_update_runs',
        'system_hardening_runs',
        'audit_logs',
        'clinical_encounters',
        'invoices',
        'payments',
        'user_role_assignments',
    ];
    foreach ($requiredTables as $logical) {
        $name = table($logical);
        $ready = function_exists('schema_table_exists') ? schema_table_exists($name) : false;
        $checks[] = [
            'key' => 'table_' . $logical,
            'label' => $logical . ' table',
            'status' => $ready ? 'pass' : 'blocked',
            'detail' => $ready ? 'Available.' : 'Missing required table.',
        ];
    }

    $ledgerColumns = ['migration_key', 'checksum_sha256', 'status', 'started_at', 'completed_at', 'execution_ms', 'attempt_count'];
    foreach ($ledgerColumns as $column) {
        $ready = function_exists('schema_column_exists') && schema_column_exists(table('system_migration_ledger'), $column);
        $checks[] = [
            'key' => 'ledger_' . $column,
            'label' => 'Migration ledger: ' . $column,
            'status' => $ready ? 'pass' : 'blocked',
            'detail' => $ready ? 'Available.' : 'Missing deterministic migration field.',
        ];
    }

    return $checks;
}

function dental_hardening_run(?PDO $pdo = null, ?string $root = null): array
{
    $root = $root ?: (defined('DENTAL_ROOT') ? DENTAL_ROOT : dirname(__DIR__));
    $scan = dental_hardening_source_scan($root);
    $checks = [];

    $checks[] = [
        'key' => 'test_foundation',
        'label' => 'Automated test foundation',
        'status' => $scan['test_file_count'] >= 5 ? 'pass' : 'blocked',
        'detail' => $scan['test_file_count'] . ' PHP test files found; at least five are required for the baseline.',
    ];
    $checks[] = [
        'key' => 'ci_foundation',
        'label' => 'CI/CD configuration',
        'status' => empty($scan['missing_required_files']) ? 'pass' : 'blocked',
        'detail' => empty($scan['missing_required_files']) ? 'Required CI, SAST, test, and release-build files are present.' : 'Missing: ' . implode(', ', array_keys($scan['missing_required_files'])),
    ];
    $checks[] = [
        'key' => 'prohibited_calls',
        'label' => 'Prohibited executable PHP calls',
        'status' => empty($scan['prohibited_calls']) ? 'pass' : 'blocked',
        'detail' => empty($scan['prohibited_calls']) ? 'No prohibited calls detected.' : implode('; ', array_slice($scan['prohibited_calls'], 0, 10)),
    ];
    $checks[] = [
        'key' => 'direct_sql_input',
        'label' => 'Direct request data in raw SQL',
        'status' => empty($scan['direct_sql_input_patterns']) ? 'pass' : 'review',
        'detail' => empty($scan['direct_sql_input_patterns']) ? 'No direct superglobal interpolation into query()/exec() detected.' : count($scan['direct_sql_input_patterns']) . ' source pattern(s) require review.',
    ];
    $checks[] = [
        'key' => 'strict_types',
        'label' => 'PHP strict-types adoption',
        'status' => $scan['strict_types_percent'] >= 90 ? 'pass' : 'review',
        'detail' => $scan['strict_types_files'] . ' of ' . $scan['php_files'] . ' files (' . $scan['strict_types_percent'] . '%).',
    ];
    $checks[] = [
        'key' => 'large_files',
        'label' => 'Large-module maintainability',
        'status' => empty($scan['large_files']) ? 'pass' : 'review',
        'detail' => empty($scan['large_files']) ? 'No PHP file exceeds 4,000 lines.' : count($scan['large_files']) . ' file(s) exceed 4,000 lines and remain refactoring candidates.',
    ];

    if ($pdo instanceof PDO) $checks = array_merge($checks, dental_hardening_database_checks($pdo));

    $counts = ['pass' => 0, 'review' => 0, 'blocked' => 0];
    foreach ($checks as $check) {
        $status = (string)($check['status'] ?? 'review');
        if (!isset($counts[$status])) $counts[$status] = 0;
        $counts[$status]++;
    }
    $fingerprint = hash('sha256', json_encode([$checks, $scan['test_files']], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));

    return [
        'version' => function_exists('app_release_version') ? app_release_version() : 'development',
        'generated_at' => date('c'),
        'ready' => $counts['blocked'] === 0,
        'counts' => $counts,
        'checks' => $checks,
        'scan' => $scan,
        'fingerprint' => $fingerprint,
    ];
}

function dental_hardening_record(PDO $pdo): array
{
    $result = dental_hardening_run($pdo);
    $tableName = table('system_hardening_runs');
    $pdo->exec('CREATE TABLE IF NOT EXISTS `' . $tableName . '` (
        `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        `release_version` VARCHAR(40) NOT NULL,
        `status` VARCHAR(20) NOT NULL,
        `blocked_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `review_count` INT UNSIGNED NOT NULL DEFAULT 0,
        `fingerprint_sha256` CHAR(64) NOT NULL,
        `result_json` LONGTEXT NOT NULL,
        `run_by` BIGINT UNSIGNED NULL,
        `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (`id`),
        KEY `idx_hardening_release_status` (`release_version`,`status`,`created_at`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci');
    $stmt = $pdo->prepare('INSERT INTO `' . $tableName . '` (release_version,status,blocked_count,review_count,fingerprint_sha256,result_json,run_by,created_at) VALUES (?,?,?,?,?,?,?,NOW())');
    $stmt->execute([
        (string)$result['version'],
        !empty($result['ready']) ? 'passed' : 'blocked',
        (int)($result['counts']['blocked'] ?? 0),
        (int)($result['counts']['review'] ?? 0),
        (string)$result['fingerprint'],
        json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
        (int)(function_exists('current_user') ? (current_user()['id'] ?? 0) : 0) ?: null,
    ]);
    if (function_exists('setting_set')) {
        setting_set('system', 'hardening_validation_last_fingerprint', (string)$result['fingerprint']);
        setting_set('system', 'hardening_validation_last_status', !empty($result['ready']) ? 'passed' : 'blocked');
        setting_set('system', 'hardening_validation_last_at', date('c'));
    }
    return $result;
}
