<?php
declare(strict_types=1);

const PROJECT_ROOT = __DIR__ . '/../..';
const WEBSITE_ROOT = PROJECT_ROOT . '/website';
const HTML_PATH = WEBSITE_ROOT . '/ikfreunde.com.html';
const JSON_PATH = WEBSITE_ROOT . '/content/site-content.de.json';
const CREDENTIALS_PATH = WEBSITE_ROOT . '/content/.editor-credentials.json';
const RESET_PATH = WEBSITE_ROOT . '/content/.editor-reset.json';
const RATE_LIMIT_PATH = WEBSITE_ROOT . '/content/.editor-rate-limit.json';
const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h
const RESET_TTL_SECONDS = 60 * 30; // 30m
const LOGIN_ACCOUNT_MAX_ATTEMPTS = 6;
const LOGIN_GLOBAL_MAX_ATTEMPTS = 20;
const LOGIN_WINDOW_SECONDS = 60 * 10; // 10m
const RESET_REQUEST_ACCOUNT_MAX_ATTEMPTS = 5;
const RESET_REQUEST_GLOBAL_MAX_ATTEMPTS = 15;
const RESET_REQUEST_WINDOW_SECONDS = 60 * 15; // 15m
const RESET_CONFIRM_ACCOUNT_MAX_ATTEMPTS = 5;
const RESET_CONFIRM_GLOBAL_MAX_ATTEMPTS = 15;
const RESET_CONFIRM_WINDOW_SECONDS = 60 * 15; // 15m

$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($uri === '/api/health') {
    respondJson(['ok' => true]);
}

if ($uri === '/api/editor/status' && $method === 'GET') {
    handleEditorStatus();
}

if ($uri === '/api/editor/claim' && $method === 'POST') {
    handleEditorClaim();
}

if ($uri === '/api/editor/login' && $method === 'POST') {
    handleEditorLogin();
}

if ($uri === '/api/editor/logout' && $method === 'POST') {
    handleEditorLogout();
}

if ($uri === '/api/editor/reset/request' && $method === 'POST') {
    handleResetRequest();
}

if ($uri === '/api/editor/reset/confirm' && $method === 'POST') {
    handleResetConfirm();
}

if ($uri === '/api/content' && $method === 'GET') {
    if (!is_file(JSON_PATH)) {
        respondJson(['error' => 'Content JSON not found.'], 404);
    }
    $raw = file_get_contents(JSON_PATH);
    if ($raw === false) {
        respondJson(['error' => 'Failed to read content JSON.'], 500);
    }
    header('Content-Type: application/json; charset=utf-8');
    echo $raw;
    exit;
}

if ($uri === '/api/save' && $method === 'POST') {
    handleSave();
}

serveStatic($uri);

function handleEditorStatus(): void
{
    $creds = readCredentials();
    $token = bearerToken();

    $claimed = is_array($creds) && (bool) ($creds['claimed'] ?? false);
    $authenticated = false;

    if ($claimed && is_string($token) && $token !== '') {
        $authenticated = verifyAndTouchSession($creds, $token);
        if ($authenticated) {
            writeCredentials($creds);
        }
    }

    respondJson([
        'ok' => true,
        'claimed' => $claimed,
        'authenticated' => $authenticated,
        'owner_email' => $claimed ? (string) ($creds['owner_email'] ?? '') : '',
    ]);
}

function handleEditorClaim(): void
{
    $payload = requireJsonBody();
    $email = normalizeEmail((string) ($payload['email'] ?? ''));
    $password = (string) ($payload['password'] ?? '');

    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        respondJson(['error' => 'Invalid email.'], 422);
    }
    if (strlen($password) < 8) {
        respondJson(['error' => 'Password must be at least 8 characters.'], 422);
    }

    $fp = fopen(CREDENTIALS_PATH, 'c+');
    if ($fp === false) {
        respondJson(['error' => 'Failed to open credentials store.'], 500);
    }

    if (!flock($fp, LOCK_EX)) {
        fclose($fp);
        respondJson(['error' => 'Failed to lock credentials store.'], 500);
    }

    $raw = stream_get_contents($fp);
    $current = jsonToArray($raw);
    $alreadyClaimed = is_array($current) && (bool) ($current['claimed'] ?? false);
    if ($alreadyClaimed) {
        flock($fp, LOCK_UN);
        fclose($fp);
        respondJson(['error' => 'Editor already claimed.'], 409);
    }

    $now = gmdate('c');
    $token = randomToken(48);
    $tokenHash = hash('sha256', $token);

    $creds = [
        'claimed' => true,
        'owner_email' => $email,
        'password_hash' => password_hash($password, PASSWORD_DEFAULT),
        'created_at' => $now,
        'updated_at' => $now,
        'sessions' => [
            $tokenHash => [
                'created_at' => $now,
                'expires_at' => gmdate('c', time() + SESSION_TTL_SECONDS),
            ],
        ],
    ];

    ftruncate($fp, 0);
    rewind($fp);
    fwrite($fp, toPrettyJson($creds));
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);

    respondJson([
        'ok' => true,
        'claimed' => true,
        'token' => $token,
        'owner_email' => $email,
    ]);
}

function handleEditorLogin(): void
{
    $payload = requireJsonBody();
    $email = normalizeEmail((string) ($payload['email'] ?? ''));
    $password = (string) ($payload['password'] ?? '');

    $creds = readCredentials();
    if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) {
        respondJson(['error' => 'Editor is not claimed yet.'], 409);
    }

    $ownerEmail = normalizeEmail((string) ($creds['owner_email'] ?? ''));
    $passwordHash = (string) ($creds['password_hash'] ?? '');

    $accountSubject = hash('sha256', $email !== '' ? $email : $ownerEmail);
    $accountRate = consumeRateLimit(
        'login_account',
        $accountSubject,
        LOGIN_ACCOUNT_MAX_ATTEMPTS,
        LOGIN_WINDOW_SECONDS
    );
    $globalRate = consumeRateLimit(
        'login_global',
        'global',
        LOGIN_GLOBAL_MAX_ATTEMPTS,
        LOGIN_WINDOW_SECONDS
    );
    if (!$accountRate['allowed'] || !$globalRate['allowed']) {
        respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after']));
    }

    $valid = hash_equals($ownerEmail, $email) && $passwordHash !== '' && password_verify($password, $passwordHash);
    if (!$valid) {
        respondJson(['error' => 'Invalid credentials.'], 401);
    }

    clearRateLimit('login_account', $accountSubject);

    cleanupExpiredSessions($creds);

    $token = randomToken(48);
    $tokenHash = hash('sha256', $token);
    $now = gmdate('c');
    $creds['sessions'][$tokenHash] = [
        'created_at' => $now,
        'expires_at' => gmdate('c', time() + SESSION_TTL_SECONDS),
    ];
    $creds['updated_at'] = $now;

    writeCredentials($creds);

    respondJson([
        'ok' => true,
        'token' => $token,
        'owner_email' => $ownerEmail,
    ]);
}

function handleEditorLogout(): void
{
    $token = bearerToken();
    if (!is_string($token) || $token === '') {
        respondJson(['ok' => true]);
    }

    $creds = readCredentials();
    if (!is_array($creds)) {
        respondJson(['ok' => true]);
    }

    $hash = hash('sha256', $token);
    if (isset($creds['sessions'][$hash])) {
        unset($creds['sessions'][$hash]);
        $creds['updated_at'] = gmdate('c');
        writeCredentials($creds);
    }

    respondJson(['ok' => true]);
}

function handleResetRequest(): void
{
    $payload = requireJsonBody();
    $email = normalizeEmail((string) ($payload['email'] ?? ''));
    $accountSubject = hash('sha256', $email);

    $accountRate = consumeRateLimit(
        'reset_request_account',
        $accountSubject,
        RESET_REQUEST_ACCOUNT_MAX_ATTEMPTS,
        RESET_REQUEST_WINDOW_SECONDS
    );
    $globalRate = consumeRateLimit(
        'reset_request_global',
        'global',
        RESET_REQUEST_GLOBAL_MAX_ATTEMPTS,
        RESET_REQUEST_WINDOW_SECONDS
    );
    if (!$accountRate['allowed'] || !$globalRate['allowed']) {
        respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after']));
    }

    $creds = readCredentials();
    if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) {
        respondJson(['ok' => true, 'message' => 'If the account exists, a reset token has been created.']);
    }

    $ownerEmail = normalizeEmail((string) ($creds['owner_email'] ?? ''));
    if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !hash_equals($ownerEmail, $email)) {
        respondJson(['ok' => true, 'message' => 'If the account exists, a reset token has been created.']);
    }

    $token = randomToken(40);
    $tokenHash = hash('sha256', $token);
    $expiresAt = gmdate('c', time() + RESET_TTL_SECONDS);

    $pathPrefix = routePrefixFromHeader();
    $resetUrl = ($pathPrefix !== '' ? $pathPrefix : '') . '/?edit=1&reset_token=' . rawurlencode($token);

    $resetData = [
        'email' => $ownerEmail,
        'token_hash' => $tokenHash,
        'token_plain' => $token,
        'reset_url' => $resetUrl,
        'created_at' => gmdate('c'),
        'expires_at' => $expiresAt,
    ];

    file_put_contents(RESET_PATH, toPrettyJson($resetData) . "\n", LOCK_EX);

    respondJson([
        'ok' => true,
        'message' => 'If the account exists, a reset token has been created.',
    ]);
}

function handleResetConfirm(): void
{
    $payload = requireJsonBody();
    $token = trim((string) ($payload['token'] ?? ''));
    $newPassword = (string) ($payload['newPassword'] ?? '');

    $resetRawForLimit = is_file(RESET_PATH) ? file_get_contents(RESET_PATH) : false;
    $resetDataForLimit = jsonToArray($resetRawForLimit);
    $emailForLimit = is_array($resetDataForLimit) ? normalizeEmail((string) ($resetDataForLimit['email'] ?? '')) : '';
    $accountSubject = hash('sha256', $emailForLimit !== '' ? $emailForLimit : 'unknown');

    $accountRate = consumeRateLimit(
        'reset_confirm_account',
        $accountSubject,
        RESET_CONFIRM_ACCOUNT_MAX_ATTEMPTS,
        RESET_CONFIRM_WINDOW_SECONDS
    );
    $globalRate = consumeRateLimit(
        'reset_confirm_global',
        'global',
        RESET_CONFIRM_GLOBAL_MAX_ATTEMPTS,
        RESET_CONFIRM_WINDOW_SECONDS
    );
    if (!$accountRate['allowed'] || !$globalRate['allowed']) {
        respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after']));
    }

    if ($token === '') {
        respondJson(['error' => 'Missing reset token.'], 422);
    }
    if (strlen($newPassword) < 8) {
        respondJson(['error' => 'Password must be at least 8 characters.'], 422);
    }

    $resetRaw = is_file(RESET_PATH) ? file_get_contents(RESET_PATH) : false;
    $resetData = jsonToArray($resetRaw);
    if (!is_array($resetData)) {
        respondJson(['error' => 'Reset token not found.'], 404);
    }

    $expiresAt = strtotime((string) ($resetData['expires_at'] ?? ''));
    if ($expiresAt === false || $expiresAt < time()) {
        @unlink(RESET_PATH);
        respondJson(['error' => 'Reset token expired.'], 410);
    }

    $tokenHash = hash('sha256', $token);
    $expectedHash = (string) ($resetData['token_hash'] ?? '');
    if ($expectedHash === '' || !hash_equals($expectedHash, $tokenHash)) {
        respondJson(['error' => 'Invalid reset token.'], 401);
    }

    $creds = readCredentials();
    if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) {
        respondJson(['error' => 'Editor credentials not found.'], 404);
    }

    $creds['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT);
    $creds['sessions'] = [];
    $creds['updated_at'] = gmdate('c');
    writeCredentials($creds);

    clearRateLimit('reset_confirm_account', $accountSubject);
    @unlink(RESET_PATH);

    respondJson(['ok' => true]);
}

function handleSave(): void
{
    $creds = requireAuthenticatedEditor();

    $body = file_get_contents('php://input');
    if ($body === false || trim($body) === '') {
        respondJson(['error' => 'Missing request body.'], 400);
    }

    $payload = json_decode($body, true);
    if (!is_array($payload)) {
        respondJson(['error' => 'Invalid JSON body.'], 400);
    }

    $textChanges = is_array($payload['textChanges'] ?? null) ? $payload['textChanges'] : [];
    $imageChanges = is_array($payload['imageChanges'] ?? null) ? $payload['imageChanges'] : [];
    $dryRun = (bool) ($payload['dryRun'] ?? false);

    $html = file_get_contents(HTML_PATH);
    if ($html === false) {
        respondJson(['error' => 'Failed to read HTML file.'], 500);
    }

    $jsonRaw = file_get_contents(JSON_PATH);
    if ($jsonRaw === false) {
        respondJson(['error' => 'Failed to read content JSON file.'], 500);
    }

    $jsonData = json_decode($jsonRaw, true);
    if (!is_array($jsonData)) {
        respondJson(['error' => 'Content JSON is invalid.'], 500);
    }

    $dom = new DOMDocument();
    libxml_use_internal_errors(true);
    $loaded = $dom->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET);
    if (!$loaded) {
        respondJson(['error' => 'Failed to parse HTML.'], 500);
    }

    $xpath = new DOMXPath($dom);

    $appliedText = applyTextChanges($xpath, $jsonData, $textChanges);
    $appliedImage = applyImageChanges($xpath, $jsonData, $imageChanges);

    if (!$dryRun) {
        backupFile(HTML_PATH);
        backupFile(JSON_PATH);

        $savedHtml = $dom->saveHTML();
        if ($savedHtml === false) {
            respondJson(['error' => 'Failed to serialize HTML after changes.'], 500);
        }

        $savedJson = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($savedJson === false) {
            respondJson(['error' => 'Failed to encode JSON after changes.'], 500);
        }

        if (file_put_contents(HTML_PATH, $savedHtml) === false) {
            respondJson(['error' => 'Failed to save HTML file.'], 500);
        }

        if (file_put_contents(JSON_PATH, $savedJson . "\n") === false) {
            respondJson(['error' => 'Failed to save JSON file.'], 500);
        }
    }

    cleanupExpiredSessions($creds);
    writeCredentials($creds);

    respondJson([
        'ok' => true,
        'applied' => [
            'text' => $appliedText,
            'images' => $appliedImage,
        ],
        'dryRun' => $dryRun,
    ]);
}

function requireAuthenticatedEditor(): array
{
    $token = bearerToken();
    if (!is_string($token) || $token === '') {
        respondJson(['error' => 'Missing editor authorization.'], 401);
    }

    $creds = readCredentials();
    if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) {
        respondJson(['error' => 'Editor is not claimed yet.'], 409);
    }

    $ok = verifyAndTouchSession($creds, $token);
    if (!$ok) {
        respondJson(['error' => 'Invalid or expired editor session.'], 401);
    }

    return $creds;
}

function verifyAndTouchSession(array &$creds, string $token): bool
{
    cleanupExpiredSessions($creds);

    $hash = hash('sha256', $token);
    if (!isset($creds['sessions'][$hash]) || !is_array($creds['sessions'][$hash])) {
        return false;
    }

    $creds['sessions'][$hash]['expires_at'] = gmdate('c', time() + SESSION_TTL_SECONDS);
    $creds['updated_at'] = gmdate('c');
    return true;
}

function cleanupExpiredSessions(array &$creds): void
{
    if (!is_array($creds['sessions'] ?? null)) {
        $creds['sessions'] = [];
        return;
    }

    $now = time();
    foreach ($creds['sessions'] as $hash => $session) {
        $exp = strtotime((string) ($session['expires_at'] ?? ''));
        if ($exp === false || $exp < $now) {
            unset($creds['sessions'][$hash]);
        }
    }
}

function bearerToken(): ?string
{
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!is_string($header)) {
        return null;
    }

    if (preg_match('/^Bearer\s+(.+)$/i', $header, $m) === 1) {
        return trim($m[1]);
    }

    return null;
}

function routePrefixFromHeader(): string
{
    $prefix = $_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? '';
    if (!is_string($prefix) || $prefix === '' || $prefix === '/') {
        return '';
    }

    $prefix = '/' . trim($prefix, '/');
    return $prefix;
}

function requireJsonBody(): array
{
    $raw = file_get_contents('php://input');
    if ($raw === false || trim($raw) === '') {
        respondJson(['error' => 'Missing request body.'], 400);
    }

    $data = json_decode($raw, true);
    if (!is_array($data)) {
        respondJson(['error' => 'Invalid JSON body.'], 400);
    }

    return $data;
}

function readCredentials(): ?array
{
    if (!is_file(CREDENTIALS_PATH)) {
        return null;
    }

    $raw = file_get_contents(CREDENTIALS_PATH);
    $data = jsonToArray($raw);
    return is_array($data) ? $data : null;
}

function writeCredentials(array $data): void
{
    file_put_contents(CREDENTIALS_PATH, toPrettyJson($data) . "\n", LOCK_EX);
}

function consumeRateLimit(string $bucket, string $subject, int $maxAttempts, int $windowSeconds): array
{
    $subjectKey = $bucket . '|' . $subject;
    $store = readRateLimitStore();
    $now = time();

    foreach ($store as $key => $entry) {
        $start = (int) ($entry['window_start'] ?? 0);
        if ($start <= 0 || ($start + $windowSeconds) <= $now) {
            unset($store[$key]);
        }
    }

    if (!isset($store[$subjectKey]) || !is_array($store[$subjectKey])) {
        $store[$subjectKey] = [
            'window_start' => $now,
            'count' => 0,
            'window_seconds' => $windowSeconds,
        ];
    }

    $entry = $store[$subjectKey];
    $start = (int) ($entry['window_start'] ?? $now);
    if (($start + $windowSeconds) <= $now) {
        $start = $now;
        $entry['count'] = 0;
    }

    $entry['window_start'] = $start;
    $entry['window_seconds'] = $windowSeconds;
    $entry['count'] = (int) ($entry['count'] ?? 0) + 1;
    $store[$subjectKey] = $entry;

    writeRateLimitStore($store);

    $allowed = $entry['count'] <= $maxAttempts;
    $retryAfter = max(1, ($start + $windowSeconds) - $now);

    return ['allowed' => $allowed, 'retry_after' => $retryAfter];
}

function clearRateLimit(string $bucket, string $subject): void
{
    $subjectKey = $bucket . '|' . $subject;
    $store = readRateLimitStore();
    if (isset($store[$subjectKey])) {
        unset($store[$subjectKey]);
        writeRateLimitStore($store);
    }
}

function readRateLimitStore(): array
{
    if (!is_file(RATE_LIMIT_PATH)) {
        return [];
    }

    $raw = file_get_contents(RATE_LIMIT_PATH);
    $data = jsonToArray($raw);
    return is_array($data) ? $data : [];
}

function writeRateLimitStore(array $store): void
{
    file_put_contents(RATE_LIMIT_PATH, toPrettyJson($store) . "\n", LOCK_EX);
}

function jsonToArray(string|false $raw): ?array
{
    if (!is_string($raw) || trim($raw) === '') {
        return null;
    }
    $decoded = json_decode($raw, true);
    return is_array($decoded) ? $decoded : null;
}

function toPrettyJson(array $data): string
{
    return (string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

function randomToken(int $bytes): string
{
    return bin2hex(random_bytes($bytes));
}

function normalizeEmail(string $email): string
{
    return strtolower(trim($email));
}

function applyTextChanges(DOMXPath $xpath, array &$jsonData, array $changes): int
{
    $sectionChanges = [];
    $sharedCommonChanges = [];

    foreach ($changes as $change) {
        if (!is_array($change)) {
            continue;
        }

        $scope = (string) ($change['scope'] ?? '');
        $key = (string) ($change['key'] ?? '');
        $value = normalizeText((string) ($change['value'] ?? ''));

        if ($scope === 'section') {
            $section = (string) ($change['section'] ?? '');
            if ($section !== '' && $key !== '') {
                $sectionChanges[$section][$key] = $value;
            }
            continue;
        }

        if ($scope === 'shared' && $key !== '') {
            $sharedCommonChanges[$key] = $value;
        }
    }

    foreach ($sectionChanges as $section => $pairs) {
        foreach ($pairs as $key => $value) {
            if (isset($jsonData['sections'][$section]['texts'][$key])) {
                $jsonData['sections'][$section]['texts'][$key] = $value;
            }
        }
    }

    foreach ($sharedCommonChanges as $key => $value) {
        if (isset($jsonData['shared']['common_texts'][$key])) {
            $jsonData['shared']['common_texts'][$key] = $value;
        }
    }

    $applied = 0;

    foreach (getSectionRoots($xpath) as $section => $root) {
        if (!$root instanceof DOMNode || !isset($sectionChanges[$section])) {
            continue;
        }
        $applied += applySectionTextToHtml($xpath, $root, $sectionChanges[$section]);
    }

    foreach (getSharedRoots($xpath) as $area => $root) {
        if (!$root instanceof DOMNode) {
            continue;
        }

        $keys = $jsonData['shared'][$area]['text_keys'] ?? [];
        if (!is_array($keys) || count($keys) === 0) {
            continue;
        }

        $textNodes = extractVisibleTextNodes($xpath, $root);
        foreach ($textNodes as $index => $node) {
            if (!$node instanceof DOMText) {
                continue;
            }
            $commonKey = $keys[$index] ?? null;
            if (!is_string($commonKey) || $commonKey === '') {
                continue;
            }
            if (!isset($sharedCommonChanges[$commonKey])) {
                continue;
            }
            $node->nodeValue = $sharedCommonChanges[$commonKey];
            $applied++;
        }
    }

    return $applied;
}

function applyImageChanges(DOMXPath $xpath, array &$jsonData, array $changes): int
{
    $sectionChanges = [];
    $sharedChanges = [];

    foreach ($changes as $change) {
        if (!is_array($change)) {
            continue;
        }
        $scope = (string) ($change['scope'] ?? '');
        $key = (string) ($change['key'] ?? '');
        if ($key === '') {
            continue;
        }

        $record = [
            'src' => trim((string) ($change['src'] ?? '')),
            'alt' => normalizeText((string) ($change['alt'] ?? '')),
        ];

        if ($scope === 'section') {
            $section = (string) ($change['section'] ?? '');
            if ($section !== '') {
                $sectionChanges[$section][$key] = $record;
            }
            continue;
        }

        if ($scope === 'shared') {
            $area = (string) ($change['area'] ?? '');
            if ($area !== '') {
                $sharedChanges[$area][$key] = $record;
            }
        }
    }

    foreach ($sectionChanges as $section => $pairs) {
        foreach ($pairs as $key => $record) {
            if (isset($jsonData['sections'][$section]['images'][$key])) {
                if ($record['src'] !== '') {
                    $jsonData['sections'][$section]['images'][$key]['src'] = $record['src'];
                }
                $jsonData['sections'][$section]['images'][$key]['alt'] = $record['alt'];
            }
        }
    }

    foreach ($sharedChanges as $area => $pairs) {
        foreach ($pairs as $key => $record) {
            if (isset($jsonData['shared'][$area]['images'][$key])) {
                if ($record['src'] !== '') {
                    $jsonData['shared'][$area]['images'][$key]['src'] = $record['src'];
                }
                $jsonData['shared'][$area]['images'][$key]['alt'] = $record['alt'];
            }
        }
    }

    $applied = 0;

    foreach (getSectionRoots($xpath) as $section => $root) {
        if (!$root instanceof DOMNode || !isset($sectionChanges[$section])) {
            continue;
        }
        $applied += applyImagePairsToRoot($xpath, $root, $sectionChanges[$section]);
    }

    foreach (getSharedRoots($xpath) as $area => $root) {
        if (!$root instanceof DOMNode || !isset($sharedChanges[$area])) {
            continue;
        }
        $applied += applyImagePairsToRoot($xpath, $root, $sharedChanges[$area]);
    }

    return $applied;
}

function applySectionTextToHtml(DOMXPath $xpath, DOMNode $root, array $changes): int
{
    $applied = 0;
    $counters = [];

    $nodes = extractVisibleTextNodes($xpath, $root);
    foreach ($nodes as $node) {
        if (!$node instanceof DOMText) {
            continue;
        }
        $parent = $node->parentNode;
        if (!$parent instanceof DOMElement) {
            continue;
        }

        $tag = strtolower($parent->tagName);
        $counters[$tag] = ($counters[$tag] ?? 0) + 1;
        $key = $tag . '_' . str_pad((string) $counters[$tag], 3, '0', STR_PAD_LEFT);

        if (!array_key_exists($key, $changes)) {
            continue;
        }

        $node->nodeValue = $changes[$key];
        $applied++;
    }

    return $applied;
}

function applyImagePairsToRoot(DOMXPath $xpath, DOMNode $root, array $pairs): int
{
    $applied = 0;
    $images = $xpath->query('.//img[@src]', $root);
    if (!$images instanceof DOMNodeList) {
        return 0;
    }

    $index = 1;
    foreach ($images as $imageNode) {
        if (!$imageNode instanceof DOMElement) {
            continue;
        }

        $key = 'img_' . str_pad((string) $index, 3, '0', STR_PAD_LEFT);
        $index++;

        if (!isset($pairs[$key])) {
            continue;
        }

        $record = $pairs[$key];
        $src = trim((string) ($record['src'] ?? ''));
        if ($src !== '') {
            $imageNode->setAttribute('src', $src);
        }
        $imageNode->setAttribute('alt', normalizeText((string) ($record['alt'] ?? '')));
        $applied++;
    }

    return $applied;
}

function getSectionRoots(DOMXPath $xpath): array
{
    $queries = [
        'hero' => "//main//section[contains(@class, 'module-hero-teaser')]",
        'page_header' => "//main//section[contains(@class, 'page-header')]",
        'projects' => "//main//section[contains(@class, 'module-projects-teaser')]",
        'services' => "//main//section[.//*[contains(@class, 'services-teaser__content')]]",
        'team' => "(//main//section[contains(@class, 'text-image')])[1]",
        'awards' => "//main//section[.//*[contains(@class, 'awards-teaser__content')]]",
        'contact' => "//main//section[contains(@class, 'contact-teaser')]",
        'clients' => "//main//section[.//*[contains(@class, 'clients-teaser__content')]]",
        'partners' => "(//main//section[contains(@class, 'text-image')])[2]",
    ];

    $roots = [];
    foreach ($queries as $key => $query) {
        $roots[$key] = firstNode($xpath, $query);
    }
    return $roots;
}

function getSharedRoots(DOMXPath $xpath): array
{
    return [
        'navigation' => firstNode($xpath, "//header[contains(@class, 'page-head')]"),
        'cookie_layer' => firstNode($xpath, "//*[@id='cookie-layer']"),
        'footer' => firstNode($xpath, "//footer[contains(@class, 'site-footer')]"),
    ];
}

function extractVisibleTextNodes(DOMXPath $xpath, DOMNode $root): array
{
    $result = [];
    $nodes = $xpath->query('.//text()', $root);
    if (!$nodes instanceof DOMNodeList) {
        return $result;
    }

    foreach ($nodes as $node) {
        if (!$node instanceof DOMText) {
            continue;
        }
        if (normalizeText($node->wholeText) === '') {
            continue;
        }
        if (!isVisibleTextNode($node)) {
            continue;
        }
        $result[] = $node;
    }

    return $result;
}

function isVisibleTextNode(DOMText $textNode): bool
{
    $skipTags = ['script', 'style', 'noscript', 'template', 'svg', 'path', 'defs'];
    $node = $textNode->parentNode;
    while ($node instanceof DOMNode) {
        if ($node instanceof DOMElement) {
            $tag = strtolower($node->tagName);
            if (in_array($tag, $skipTags, true)) {
                return false;
            }
        }
        $node = $node->parentNode;
    }
    return true;
}

function normalizeText(string $value): string
{
    $value = str_replace(["\r", "\n", "\t"], ' ', $value);
    $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
    return trim($value);
}

function firstNode(DOMXPath $xpath, string $query): ?DOMNode
{
    $nodes = $xpath->query($query);
    if (!$nodes instanceof DOMNodeList || $nodes->length === 0) {
        return null;
    }
    $node = $nodes->item(0);
    return $node instanceof DOMNode ? $node : null;
}

function backupFile(string $path): void
{
    if (!is_file($path)) {
        return;
    }
    @copy($path, $path . '.bak');
}

function serveStatic(string $uri): void
{
    $cleanPath = ltrim($uri, '/');
    if ($cleanPath === '') {
        $cleanPath = 'index.html';
    }

    // Never expose internal editor auth/reset files via HTTP.
    $basename = basename($cleanPath);
    if (str_starts_with($basename, '.editor-')) {
        http_response_code(404);
        header('Content-Type: text/plain; charset=utf-8');
        echo "Not Found";
        exit;
    }

    $resolved = realpath(WEBSITE_ROOT . '/' . $cleanPath);
    $root = realpath(WEBSITE_ROOT);

    if ($resolved === false || $root === false || !str_starts_with($resolved, $root) || !is_file($resolved)) {
        http_response_code(404);
        header('Content-Type: text/plain; charset=utf-8');
        echo "Not Found";
        exit;
    }

    $ext = strtolower(pathinfo($resolved, PATHINFO_EXTENSION));
    $mime = match ($ext) {
        'html', 'htm' => 'text/html; charset=utf-8',
        'js' => 'application/javascript; charset=utf-8',
        'css' => 'text/css; charset=utf-8',
        'json' => 'application/json; charset=utf-8',
        'svg' => 'image/svg+xml',
        'jpg', 'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'webp' => 'image/webp',
        default => 'application/octet-stream',
    };

    header('Content-Type: ' . $mime);
    readfile($resolved);
    exit;
}

function respondJson(array $data, int $status = 200): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

function respondRateLimited(int $retryAfter): void
{
    header('Retry-After: ' . max(1, $retryAfter));
    respondJson(['error' => 'Too many attempts. Try again later.'], 429);
}
