<?php
/**
 * Dev-only fixture import: users, clients, completed questionnaires.
 * All entity usernames / client codes must use the fixture prefix (default dev_).
 */

const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];

const QDB_DEV_GLASS_POINTS = [
    'never_glass'    => 0,
    'little_glass'   => 1,
    'moderate_glass' => 2,
    'much_glass'     => 3,
    'extreme_glass'  => 4,
];

require_once __DIR__ . '/app_answers.php';
require_once __DIR__ . '/submissions.php';
require_once __DIR__ . '/scoring.php';

/**
 * @return array{imported: array, skipped: array, errors: string[]}
 */
function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
{
    $prefix = qdb_dev_validate_fixture($fixture);
    $password = (string)($fixture['defaultPassword'] ?? 'socialvrlab');
    if (strlen($password) < 6) {
        json_error('INVALID_FIELD', 'defaultPassword must be at least 6 characters', 400);
    }

    $imported = [
        'admins' => 0,
        'supervisors' => 0,
        'coaches' => 0,
        'clients' => 0,
        'completions' => 0,
        'submissions' => 0,
        'answers' => 0,
        'scoringRecomputed' => 0,
    ];
    $skipped = [
        'admins' => 0,
        'supervisors' => 0,
        'coaches' => 0,
        'clients' => 0,
        'completions' => 0,
    ];
    $errors = [];

    $coachIdByUsername = [];
    $supervisorIdByUsername = [];
    $affectedClients = [];

    $hash = password_hash($password, PASSWORD_DEFAULT);
    $now = time();

    $pdo->beginTransaction();
    try {
        foreach ($fixture['admins'] ?? [] as $row) {
            $username = qdb_dev_norm_username($row, $prefix);
            if (qdb_dev_user_exists($pdo, $username)) {
                $skipped['admins']++;
                continue;
            }
            $entityID = qdb_dev_entity_id('admin', $username);
            $userID = qdb_dev_user_id($username);
            $location = trim($row['location'] ?? 'Dev');
            $pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
                ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
            qdb_dev_insert_user($pdo, $userID, $username, $hash, 'admin', $entityID, $now);
            $imported['admins']++;
        }

        foreach ($fixture['supervisors'] ?? [] as $row) {
            $username = qdb_dev_norm_username($row, $prefix);
            if (qdb_dev_user_exists($pdo, $username)) {
                $skipped['supervisors']++;
                $supervisorIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
                continue;
            }
            $entityID = qdb_dev_entity_id('supervisor', $username);
            $userID = qdb_dev_user_id($username);
            $location = trim($row['location'] ?? 'Dev');
            $pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
                ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
            qdb_dev_insert_user($pdo, $userID, $username, $hash, 'supervisor', $entityID, $now);
            $supervisorIdByUsername[$username] = $entityID;
            $imported['supervisors']++;
        }

        foreach ($fixture['coaches'] ?? [] as $row) {
            $username = qdb_dev_norm_username($row, $prefix);
            $svUser = trim($row['supervisor'] ?? $row['supervisorUsername'] ?? '');
            if ($svUser === '' || !qdb_dev_has_prefix($svUser, $prefix)) {
                $errors[] = "Counselor $username: missing or invalid supervisor";
                continue;
            }
            $supervisorID = $supervisorIdByUsername[$svUser]
                ?? qdb_dev_lookup_entity_id($pdo, $svUser);
            if ($supervisorID === '') {
                $errors[] = "Counselor $username: supervisor $svUser not found";
                continue;
            }
            if (qdb_dev_user_exists($pdo, $username)) {
                $skipped['coaches']++;
                $coachIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
                continue;
            }
            $entityID = qdb_dev_entity_id('coach', $username);
            $userID = qdb_dev_user_id($username);
            $pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
                ->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
            qdb_dev_insert_user($pdo, $userID, $username, $hash, 'coach', $entityID, $now);
            $coachIdByUsername[$username] = $entityID;
            $imported['coaches']++;
        }

        foreach ($fixture['clients'] ?? [] as $row) {
            $clientCode = qdb_dev_norm_client_code($row, $prefix);
            $coachUser = trim($row['coach'] ?? $row['coachUsername'] ?? '');
            if ($coachUser === '' || !qdb_dev_has_prefix($coachUser, $prefix)) {
                $errors[] = "Client $clientCode: missing or invalid coach";
                continue;
            }
            $coachID = $coachIdByUsername[$coachUser]
                ?? qdb_dev_lookup_entity_id($pdo, $coachUser);
            if ($coachID === '') {
                $errors[] = "Client $clientCode: coach $coachUser not found";
                continue;
            }
            $chk = $pdo->prepare('SELECT 1 FROM client WHERE clientCode = :cc');
            $chk->execute([':cc' => $clientCode]);
            if ($chk->fetch()) {
                $skipped['clients']++;
                continue;
            }
            $pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
                ->execute([':cc' => $clientCode, ':cid' => $coachID]);
            $imported['clients']++;
        }

        $variant = 0;
        foreach ($fixture['completions'] ?? [] as $row) {
            $clientCode = qdb_dev_norm_client_code($row, $prefix);
            $qnID = trim($row['questionnaireID'] ?? '');
            if ($qnID === '') {
                $errors[] = 'Completion missing questionnaireID';
                continue;
            }
            $qnChk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
            $qnChk->execute([':id' => $qnID]);
            if (!$qnChk->fetch()) {
                $errors[] = "Questionnaire not in DB: $qnID";
                continue;
            }
            $clChk = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc');
            $clChk->execute([':cc' => $clientCode]);
            $coachID = $clChk->fetchColumn();
            if ($coachID === false) {
                $errors[] = "Client not found: $clientCode";
                continue;
            }
            $doneChk = $pdo->prepare(
                'SELECT 1 FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
            );
            $doneChk->execute([':cc' => $clientCode, ':qn' => $qnID]);
            if ($doneChk->fetch()) {
                $skipped['completions']++;
                continue;
            }

            $coachUserStmt = $pdo->prepare(
                "SELECT u.userID FROM users u
                 WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1"
            );
            $coachUserStmt->execute([':cid' => $coachID]);
            $coachUserID = (string)($coachUserStmt->fetchColumn() ?: '');
            $tokenRec = ['userID' => $coachUserID, 'role' => 'coach'];

            $uploads = qdb_dev_normalize_uploads($row, $variant, $now);
            $archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission');

            foreach ($uploads as $uploadIndex => $upload) {
                $variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex));
                $completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
                $startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600));

                $answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed);
                $answerCount = qdb_dev_persist_submission(
                    $pdo,
                    $qnID,
                    $clientCode,
                    (string)$coachID,
                    $answers,
                    $startedAt,
                    $completedAt
                );
                $imported['answers'] += $answerCount;

                if ($archiveSubmissions) {
                    $spStmt = $pdo->prepare(
                        'SELECT sumPoints FROM completed_questionnaire
                         WHERE clientCode = :cc AND questionnaireID = :qn'
                    );
                    $spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
                    $sumPoints = (int)($spStmt->fetchColumn() ?: 0);
                    qdb_record_submission_after_submit(
                        $pdo,
                        $clientCode,
                        $qnID,
                        $tokenRec,
                        $startedAt,
                        $completedAt,
                        $sumPoints,
                        (string)$coachID,
                        $completedAt
                    );
                    $imported['submissions']++;
                }
            }

            $imported['completions']++;
            $affectedClients[$clientCode] = true;
            $variant++;
        }

        $upperPrefix = strtoupper(rtrim($prefix, '_'));
        $devClientStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
        $devClientStmt->execute([':pfx' => $upperPrefix . '%']);
        foreach ($devClientStmt->fetchAll(PDO::FETCH_COLUMN) as $cc) {
            $affectedClients[(string)$cc] = true;
        }
        if ($affectedClients !== []) {
            $imported['scoringRecomputed'] = qdb_recompute_scores_for_clients(
                $pdo,
                array_keys($affectedClients)
            );
        }

        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }

    return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors];
}

function qdb_dev_validate_fixture(array $fixture): string
{
    $prefix = trim((string)($fixture['prefix'] ?? 'dev_'));
    if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
        json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
    }
    $fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0);
    if ($fixtureVersion !== 1 && $fixtureVersion !== 2) {
        json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400);
    }
    return $prefix;
}

/**
 * @return list<array{submittedAt: int, startedAt: int, variant: int}>
 */
function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array
{
    if (!empty($row['uploads']) && is_array($row['uploads'])) {
        $out = [];
        foreach ($row['uploads'] as $i => $upload) {
            if (!is_array($upload)) {
                continue;
            }
            $submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
            $startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600));
            $out[] = [
                'submittedAt' => $submittedAt,
                'startedAt'   => $startedAt,
                'variant'     => (int)($upload['variant'] ?? ($fallbackVariant + $i)),
            ];
        }
        if ($out !== []) {
            usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
            return $out;
        }
    }

    $versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1));
    $finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400);
    $uploads = [];
    for ($v = 0; $v < $versionCount; $v++) {
        $gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5);
        $completedAt = $finalCompleted - ($gapDays * 86400);
        $uploads[] = [
            'submittedAt' => $completedAt,
            'startedAt'   => isset($row['startedAt']) && $v === $versionCount - 1
                ? (int)$row['startedAt']
                : ($completedAt - 600 - ($v * 120)),
            'variant'     => $fallbackVariant + $v,
        ];
    }
    usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
    return $uploads;
}

function qdb_dev_has_prefix(string $value, string $prefix): bool
{
    return str_starts_with(strtolower($value), strtolower($prefix))
        || str_starts_with(strtoupper($value), strtoupper(rtrim($prefix, '_')));
}

function qdb_dev_norm_username(array $row, string $prefix): string
{
    $username = trim($row['username'] ?? '');
    if ($username === '' || !qdb_dev_has_prefix($username, $prefix)) {
        json_error('INVALID_FIELD', "Username must use prefix $prefix", 400);
    }
    return $username;
}

function qdb_dev_norm_client_code(array $row, string $prefix): string
{
    $code = trim($row['clientCode'] ?? '');
    $upperPrefix = strtoupper(rtrim($prefix, '_'));
    if ($code === '' || !str_starts_with($code, $upperPrefix)) {
        json_error('INVALID_FIELD', "clientCode must start with $upperPrefix", 400);
    }
    return $code;
}

function qdb_dev_entity_id(string $role, string $username): string
{
    return 'dev_ent_' . $role . '_' . substr(hash('sha256', $username), 0, 24);
}

function qdb_dev_user_id(string $username): string
{
    return 'dev_uid_' . substr(hash('sha256', 'user:' . $username), 0, 28);
}

function qdb_dev_user_exists(PDO $pdo, string $username): bool
{
    $stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u');
    $stmt->execute([':u' => $username]);
    return (bool)$stmt->fetch();
}

function qdb_dev_lookup_entity_id(PDO $pdo, string $username): string
{
    $stmt = $pdo->prepare('SELECT entityID FROM users WHERE username = :u');
    $stmt->execute([':u' => $username]);
    $id = $stmt->fetchColumn();
    return $id !== false ? (string)$id : '';
}

function qdb_dev_insert_user(
    PDO $pdo,
    string $userID,
    string $username,
    string $hash,
    string $role,
    string $entityID,
    int $now
): void {
    $mcp = 0;
    $pdo->prepare(
        'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
         VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)'
    )->execute([
        ':uid' => $userID,
        ':u' => $username,
        ':hash' => $hash,
        ':role' => $role,
        ':eid' => $entityID,
        ':mcp' => $mcp,
        ':now' => $now,
    ]);
}

/**
 * @return list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}>
 */
function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $variant): array
{
    $stmt = $pdo->prepare(
        'SELECT questionID, type, configJson, orderIndex
         FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
    );
    $stmt->execute([':qn' => $qnID]);
    $questions = $stmt->fetchAll(PDO::FETCH_ASSOC);

    $aoStmt = $pdo->prepare(
        'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points, ao.orderIndex
         FROM answer_option ao
         JOIN question q ON q.questionID = ao.questionID
         WHERE q.questionnaireID = :qn
         ORDER BY ao.orderIndex'
    );
    $aoStmt->execute([':qn' => $qnID]);
    $optionsByQuestion = [];
    foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
        $optionsByQuestion[$ao['questionID']][] = $ao;
    }

    $answers = [];
    $freeTextSamples = ['Testantwort', 'Dev-Eingabe', 'Beispieltext', 'N/A'];

    foreach ($questions as $q) {
        $type = $q['type'] ?? '';
        $localId = qdb_question_local_id($q['questionID'], $qnID);
        $config = json_decode($q['configJson'] ?? '{}', true) ?: [];
        $v = $variant + (int)($q['orderIndex'] ?? 0);

        switch ($type) {
            case 'last_page':
                break;

            case 'glass_scale_question':
                $symData = [];
                foreach ($config['symptoms'] ?? [] as $si => $symptomKey) {
                    $sk = trim((string)$symptomKey);
                    if ($sk === '') {
                        continue;
                    }
                    $symData[$sk] = QDB_DEV_GLASS_LABELS[($v + $si) % count(QDB_DEV_GLASS_LABELS)];
                }
                if ($symData !== []) {
                    $answers[] = [
                        'questionID' => $localId,
                        'freeTextValue' => json_encode($symData, JSON_UNESCAPED_UNICODE),
                    ];
                }
                break;

            case 'radio_question':
                $opts = $optionsByQuestion[$q['questionID']] ?? [];
                if ($opts) {
                    $pick = $opts[$v % count($opts)];
                    $key = qdb_option_key($pick) ?: $pick['defaultText'];
                    $answers[] = ['questionID' => $localId, 'answerOptionKey' => $key];
                }
                break;

            case 'multi_check_box_question':
                $opts = $optionsByQuestion[$q['questionID']] ?? [];
                if ($opts) {
                    $pickCount = min(5, max(2, count($opts) > 20 ? 4 : 2), count($opts));
                    $keys = [];
                    for ($i = 0; $i < $pickCount; $i++) {
                        $pick = $opts[($v + $i) % count($opts)];
                        $key = qdb_option_key($pick) ?: $pick['defaultText'];
                        if ($key !== '') {
                            $keys[] = $key;
                        }
                    }
                    if ($keys !== []) {
                        $answers[] = [
                            'questionID'    => $localId,
                            'freeTextValue' => qdb_encode_multi_check_values($keys),
                        ];
                    }
                }
                break;

            case 'string_spinner':
                $opts = $config['options'] ?? [];
                if (is_array($opts) && $opts !== []) {
                    $answers[] = [
                        'questionID' => $localId,
                        'freeTextValue' => (string)$opts[$v % count($opts)],
                    ];
                }
                break;

            case 'value_spinner':
            case 'slider_question':
                $range = $config['range'] ?? ['min' => 0, 'max' => 10];
                $min = (int)($range['min'] ?? 0);
                $max = (int)($range['max'] ?? 10);
                if ($max < $min) {
                    $max = $min;
                }
                $span = max(1, $max - $min + 1);
                $answers[] = [
                    'questionID' => $localId,
                    'numericValue' => (float)($min + ($v % $span)),
                ];
                break;

            case 'date_spinner':
                $precision = $config['precision'] ?? 'full';
                $dateRange = ($config['dateRange'] ?? 'past') === 'future' ? 'future' : 'past';
                $year = $dateRange === 'future'
                    ? ((int)date('Y') + ($v % 5))
                    : (2018 + ($v % 6));
                $month = 1 + ($v % 12);
                $day = 1 + ($v % 28);
                $dateValue = match ($precision) {
                    'year' => sprintf('%04d', $year),
                    'year_month' => sprintf('%04d-%02d', $year, $month),
                    default => sprintf('%04d-%02d-%02d', $year, $month, $day),
                };
                $answers[] = [
                    'questionID' => $localId,
                    'freeTextValue' => $dateValue,
                ];
                break;

            case 'free_text':
                $answers[] = [
                    'questionID' => $localId,
                    'freeTextValue' => $freeTextSamples[$v % count($freeTextSamples)],
                ];
                break;

            case 'client_coach_code_question':
                $answers[] = [
                    'questionID' => $localId,
                    'freeTextValue' => 'DEV-IMPORT',
                ];
                break;

            default:
                break;
        }
    }

    return $answers;
}

/**
 * @param list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}> $answers
 */
function qdb_dev_persist_submission(
    PDO $pdo,
    string $qnID,
    string $clientCode,
    string $assignedByCoach,
    array $answers,
    int $startedAt,
    int $completedAt
): int {
    $qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn');
    $qRows->execute([':qn' => $qnID]);
    $shortIdMap = [];
    $typeByFullId = [];
    $freeTextMaxLen = [];
    foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
        $fullId = $qRow['questionID'];
        $parts = explode('__', $fullId);
        $shortIdMap[end($parts)] = $fullId;
        $typeByFullId[$fullId] = $qRow['type'] ?? '';
        if (($qRow['type'] ?? '') === 'free_text') {
            $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
            $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
        }
    }

    $aoRows = $pdo->prepare(
        'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
         FROM answer_option ao
         JOIN question q ON q.questionID = ao.questionID
         WHERE q.questionnaireID = :qn'
    );
    $aoRows->execute([':qn' => $qnID]);
    $optionMap = [];
    foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
        $optionMap[$ao['questionID']][$ao['defaultText']] = [
            'answerOptionID' => $ao['answerOptionID'],
            'points'         => (int)$ao['points'],
        ];
    }

    $sumPoints = 0;
    $written = 0;
    $answeredAt = $completedAt;
    $answerInsert = $pdo->prepare(
        'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
         VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) '
        . qdb_upsert_update(
            ['clientCode', 'questionID'],
            [
                'answerOptionID' => 'excluded.answerOptionID',
                'freeTextValue' => 'excluded.freeTextValue',
                'numericValue' => 'excluded.numericValue',
                'answeredAt' => 'excluded.answeredAt',
            ]
        )
    );

    foreach ($answers as $a) {
        $shortId = trim($a['questionID'] ?? '');
        if ($shortId === '') {
            continue;
        }

        $fullQID = $shortIdMap[$shortId] ?? null;
        if ($fullQID === null) {
            continue;
        }

        $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
        $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
        if ($freeTextValue !== null && $freeTextValue !== '') {
            $maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
            $freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
            if ($freeTextValue === '') {
                continue;
            }
        }

        $answerOptionID = null;
        $qType = $typeByFullId[$fullQID] ?? '';

        if ($qType === 'glass_scale_question' && $freeTextValue !== null && $freeTextValue !== '') {
            $decoded = json_decode($freeTextValue, true);
            if (is_array($decoded)) {
                foreach ($decoded as $label) {
                    $label = trim((string)$label);
                    if ($label !== '') {
                        $sumPoints += QDB_DEV_GLASS_POINTS[$label] ?? 0;
                    }
                }
            }
        } elseif ($qType === 'multi_check_box_question' && $freeTextValue !== null && $freeTextValue !== '') {
            foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
                if (isset($optionMap[$fullQID][$mk])) {
                    $sumPoints += $optionMap[$fullQID][$mk]['points'];
                }
            }
        } else {
            $optionKey = $a['answerOptionKey'] ?? null;
            if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
                $opt = $optionMap[$fullQID][(string)$optionKey];
                $answerOptionID = $opt['answerOptionID'];
                $sumPoints += $opt['points'];
            }
        }

        $answerInsert->execute([
            ':cc'   => $clientCode,
            ':qid'  => $fullQID,
            ':aoid' => $answerOptionID,
            ':ftv'  => $freeTextValue,
            ':nv'   => $numericValue,
            ':at'   => $answeredAt,
        ]);
        $written++;
    }

    $pdo->prepare(
        'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
         VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) '
        . qdb_upsert_update(
            ['clientCode', 'questionnaireID'],
            [
                'assignedByCoach' => 'excluded.assignedByCoach',
                'status' => "'completed'",
                'startedAt' => 'COALESCE(excluded.startedAt, startedAt)',
                'completedAt' => 'excluded.completedAt',
                'sumPoints' => 'excluded.sumPoints',
            ]
        )
    )->execute([
        ':cc'  => $clientCode,
        ':qn'  => $qnID,
        ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
        ':sa'  => $startedAt,
        ':ca'  => $completedAt,
        ':sp'  => $sumPoints,
    ]);

    return $written;
}

/** @param list<string> $ids */
function qdb_dev_delete_by_ids(PDO $pdo, string $table, string $column, array $ids): int
{
    $ids = array_values(array_unique(array_filter($ids, static fn($id) => $id !== '' && $id !== null)));
    if ($ids === []) {
        return 0;
    }
    $total = 0;
    foreach (array_chunk($ids, 200) as $chunk) {
        $ph = implode(',', array_fill(0, count($chunk), '?'));
        $stmt = $pdo->prepare("DELETE FROM $table WHERE $column IN ($ph)");
        $stmt->execute($chunk);
        $total += $stmt->rowCount();
    }
    return $total;
}

/**
 * Remove dev-prefixed test users, clients, and their answers (does not touch other accounts).
 *
 * @return array{deleted: array<string, int>}
 */
function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
{
    $usernamePrefix = trim((string)($options['usernamePrefix'] ?? 'dev_'));
    $clientPrefix = trim((string)($options['clientCodePrefix'] ?? 'DEV-CL-'));
    if ($usernamePrefix === '' || stripos($usernamePrefix, 'dev') !== 0) {
        json_error('INVALID_FIELD', 'usernamePrefix must start with "dev"', 400);
    }
    if ($clientPrefix === '' || stripos($clientPrefix, 'DEV') !== 0) {
        json_error('INVALID_FIELD', 'clientCodePrefix must start with "DEV"', 400);
    }

    $userLike = $usernamePrefix . '%';
    $clientLike = $clientPrefix . '%';

    $deleted = [
        'submissions' => 0,
        'followup_notes' => 0,
        'client_answers' => 0,
        'completed_questionnaires' => 0,
        'clients' => 0,
        'sessions' => 0,
        'users' => 0,
        'coaches' => 0,
        'supervisors' => 0,
        'admins' => 0,
    ];

    $coachIds = [];
    $stmt = $pdo->prepare('SELECT coachID FROM coach WHERE username LIKE :pfx');
    $stmt->execute([':pfx' => $userLike]);
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
        $coachIds[$id] = true;
    }

    $supervisorIds = [];
    $stmt = $pdo->prepare('SELECT supervisorID FROM supervisor WHERE username LIKE :pfx');
    $stmt->execute([':pfx' => $userLike]);
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
        $supervisorIds[$id] = true;
    }

    $adminIds = [];
    $stmt = $pdo->prepare('SELECT adminID FROM admin WHERE username LIKE :pfx');
    $stmt->execute([':pfx' => $userLike]);
    foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
        $adminIds[$id] = true;
    }

    $userIds = [];
    $userRows = $pdo->prepare('SELECT userID, role, entityID FROM users WHERE username LIKE :pfx');
    $userRows->execute([':pfx' => $userLike]);
    foreach ($userRows->fetchAll(PDO::FETCH_ASSOC) as $u) {
        $userIds[$u['userID']] = true;
        switch ($u['role']) {
            case 'coach':
                $coachIds[$u['entityID']] = true;
                break;
            case 'supervisor':
                $supervisorIds[$u['entityID']] = true;
                break;
            case 'admin':
                $adminIds[$u['entityID']] = true;
                break;
        }
    }

    $coachIdList = array_keys($coachIds);
    $clientCodes = [];
    $ccStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
    $ccStmt->execute([':pfx' => $clientLike]);
    foreach ($ccStmt->fetchAll(PDO::FETCH_COLUMN) as $code) {
        $clientCodes[$code] = true;
    }
    if ($coachIdList !== []) {
        foreach (array_chunk($coachIdList, 200) as $chunk) {
            $ph = implode(',', array_fill(0, count($chunk), '?'));
            $byCoach = $pdo->prepare("SELECT clientCode FROM client WHERE coachID IN ($ph)");
            $byCoach->execute($chunk);
            foreach ($byCoach->fetchAll(PDO::FETCH_COLUMN) as $code) {
                $clientCodes[$code] = true;
            }
        }
    }
    $clientCodeList = array_keys($clientCodes);

    $pdo->beginTransaction();
    try {
        if ($clientCodeList !== []) {
            $responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList);
            $deleted['submissions'] += $responseDeleted['submissions'];
            $deleted['followup_notes'] += $responseDeleted['followup_notes'];
            $deleted['client_answers'] += $responseDeleted['client_answers'];
            $deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires'];
        }
        if ($coachIdList !== []) {
            $deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList);
            $deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
                $pdo,
                'completed_questionnaire',
                'assignedByCoach',
                $coachIdList
            );
        }

        if ($clientCodeList !== []) {
            $deleted['clients'] += qdb_dev_delete_by_ids($pdo, 'client', 'clientCode', $clientCodeList);
        }
        if ($coachIdList !== []) {
            foreach (array_chunk($coachIdList, 200) as $chunk) {
                $ph = implode(',', array_fill(0, count($chunk), '?'));
                $delCl = $pdo->prepare("DELETE FROM client WHERE coachID IN ($ph)");
                $delCl->execute($chunk);
                $deleted['clients'] += $delCl->rowCount();
            }
        }
        $delClPrefix = $pdo->prepare('DELETE FROM client WHERE clientCode LIKE :pfx');
        $delClPrefix->execute([':pfx' => $clientLike]);
        $deleted['clients'] += $delClPrefix->rowCount();

        $deleted['sessions'] = qdb_dev_delete_by_ids($pdo, 'session', 'userID', array_keys($userIds));

        $deleted['users'] = qdb_dev_delete_by_ids($pdo, 'users', 'userID', array_keys($userIds));
        $delUsersLike = $pdo->prepare('DELETE FROM users WHERE username LIKE :pfx');
        $delUsersLike->execute([':pfx' => $userLike]);
        $deleted['users'] += $delUsersLike->rowCount();

        $deleted['coaches'] = qdb_dev_delete_by_ids($pdo, 'coach', 'coachID', $coachIdList);
        $delCoachLike = $pdo->prepare('DELETE FROM coach WHERE username LIKE :pfx');
        $delCoachLike->execute([':pfx' => $userLike]);
        $deleted['coaches'] += $delCoachLike->rowCount();

        $deleted['supervisors'] = qdb_dev_delete_by_ids($pdo, 'supervisor', 'supervisorID', array_keys($supervisorIds));
        $delSvLike = $pdo->prepare('DELETE FROM supervisor WHERE username LIKE :pfx');
        $delSvLike->execute([':pfx' => $userLike]);
        $deleted['supervisors'] += $delSvLike->rowCount();

        $deleted['admins'] = qdb_dev_delete_by_ids($pdo, 'admin', 'adminID', array_keys($adminIds));
        $delAdLike = $pdo->prepare('DELETE FROM admin WHERE username LIKE :pfx');
        $delAdLike->execute([':pfx' => $userLike]);
        $deleted['admins'] += $delAdLike->rowCount();

        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }

    return ['deleted' => $deleted];
}

/**
 * Remove all clients, completions, coaches, and supervisors. Keeps admin accounts
 * and questionnaire definitions (questions, translations, etc.).
 *
 * @return array{deleted: array<string, int>, kept: array<string, int>}
 */
function qdb_wipe_all_data_except_admins(PDO $pdo): array
{
    $deleted = [
        'client_answers' => 0,
        'completed_questionnaires' => 0,
        'clients' => 0,
        'sessions' => 0,
        'users' => 0,
        'coaches' => 0,
        'supervisors' => 0,
    ];

    $keptStmt = $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'admin'");
    $keptAdmins = (int)$keptStmt->fetchColumn();

    $pdo->beginTransaction();
    try {
        // Interview data references clients/coaches; coach references supervisor.
        // Disable FK checks for this bulk wipe (same pattern as questionnaire delete).
        qdb_set_foreign_keys($pdo, false);

        $deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
        if (qdb_table_exists($pdo, 'client_answer_submission')) {
            $pdo->exec('DELETE FROM client_answer_submission');
        }
        if (qdb_table_exists($pdo, 'questionnaire_submission')) {
            $pdo->exec('DELETE FROM questionnaire_submission');
        }
        if (qdb_table_exists($pdo, 'client_followup_note')) {
            $pdo->exec('DELETE FROM client_followup_note');
        }
        $deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
        $deleted['clients'] = (int)$pdo->exec('DELETE FROM client');

        $deleted['coaches'] = (int)$pdo->exec('DELETE FROM coach');
        $deleted['supervisors'] = (int)$pdo->exec('DELETE FROM supervisor');

        $sess = $pdo->prepare(
            "DELETE FROM session WHERE userID IN (SELECT userID FROM users WHERE role != 'admin')"
        );
        $sess->execute();
        $deleted['sessions'] = $sess->rowCount();

        $users = $pdo->prepare("DELETE FROM users WHERE role != 'admin'");
        $users->execute();
        $deleted['users'] = $users->rowCount();

        qdb_set_foreign_keys($pdo, true);
        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        try {
            qdb_set_foreign_keys($pdo, true);
        } catch (Throwable $ignored) {
        }
        throw $e;
    }

    return [
        'deleted' => $deleted,
        'kept'    => ['admins' => $keptAdmins],
    ];
}
