<?php
declare(strict_types=1);

/**
 * Builds a DSGVO Art. 15 data-subject export as a ZIP file streamed
 * to the browser. Each section corresponds to one JSON file inside
 * the archive; a README.txt at the root carries the operator-supplied
 * intro text plus the boilerplate Art. 15(1) categories that are not
 * stored in the database (purposes of processing, retention info,
 * contact details for the data protection officer, etc.) and are the
 * operator's responsibility to fill in via the plugin settings.
 *
 * Data minimization principle ("so wenig wie möglich, so viel wie
 * nötig"): the exporter omits hashed secrets (password, totp_secret,
 * webauthn public_key), encrypted-at-rest mail bodies, and the
 * mail_queue (transient + already encrypted). It includes:
 *   profile.json           — account and preferences
 *   projects.json          — owned projects + memberships
 *   tasks.json             — created or assigned tasks
 *   comments.json          — comments authored
 *   ai-messages.json       — AI chat lines authored
 *   notifications.json     — notifications addressed to the user
 *   activity-log.json      — actions where user is the actor
 *   security-events.json   — login successes/failures, MFA events, exports
 *   webauthn.json          — passkey metadata (no public-key bytes)
 *   group-memberships.json — Provider Edition memberships if active
 *   invitations.json       — invitations sent to this email
 *   attachments.json       — metadata of uploaded files
 *   attachments/*          — only when `gdpr_include_attachments=1`
 */
class GdprExporter
{
    public static function buildAndStream(int $userId, bool $isAdminTriggered): void
    {
        $db   = Database::getInstance();
        $user = $db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
        if (!$user) {
            Helper::redirect('/dashboard', ['error' => Lang::get('not_found')]);
        }

        $sections = self::collectSections($db, $user);
        $readme   = self::composeReadme($user, $isAdminTriggered);

        $tmp = tempnam(sys_get_temp_dir(), 'plenvo-gdpr-');
        if ($tmp === false) {
            Helper::redirect('/dashboard', ['error' => Lang::get('error')]);
        }
        // Guarantee cleanup even if the client aborts mid-readfile, in which
        // case the regular @unlink() at the end of this method never runs and
        // the temp file would otherwise accumulate in sys_get_temp_dir().
        register_shutdown_function(static function () use ($tmp): void { @unlink($tmp); });
        // tempnam creates a 0-byte file; ZipArchive needs to open it.
        $zip = new \ZipArchive();
        if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) {
            @unlink($tmp);
            Helper::redirect('/dashboard', ['error' => Lang::get('error')]);
        }
        $zip->addFromString('README.txt', $readme);
        foreach ($sections as $name => $payload) {
            $zip->addFromString($name . '.json', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
        }
        if (Helper::getSetting('gdpr_include_attachments', '0') === '1') {
            self::addAttachmentFiles($zip, $db, $userId);
        }
        $zip->close();

        $size     = (int)@filesize($tmp);
        $filename = sprintf(
            'plenvo-gdpr-export-%s-%s.zip',
            preg_replace('/[^A-Za-z0-9_.-]/', '_', (string)($user['username'] ?? 'user')),
            date('Y-m-d')
        );

        // Stream the file with strict download headers; unlink immediately
        // after the read so the temp file does not survive the request.
        // No persistent storage on the server — DSGVO data minimisation
        // applies to our own intermediate artefacts too.
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Content-Length: ' . $size);
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');
        header('X-Content-Type-Options: nosniff');
        readfile($tmp);
        @unlink($tmp);
        exit;
    }

    private static function collectSections(Database $db, array $user): array
    {
        $uid    = (int)$user['id'];
        $email  = (string)$user['email'];

        // Profile: trim secrets. Password hash, TOTP secret, MFA codes
        // and WebAuthn public-key bytes are NOT personal data the user
        // benefits from receiving. Verification/reset/pending-email tokens
        // are stored as SHA-256 hashes of the live secret mailed out, so
        // they are preimage-resistant on paper, but they remain credential
        // material and are excluded for consistency with the stated
        // "no token leakage" policy and to avoid leaking pending-state info.
        $profile = [];
        foreach ($user as $k => $v) {
            if (in_array($k, [
                'password',
                'totp_secret', 'totp_last_counter',
                'reset_token', 'reset_token_expires',
                'email_verify_token', 'email_verify_expires',
                'pending_email_token', 'pending_email_expires',
            ], true)) continue;
            $profile[$k] = $v;
        }

        $sections = [
            'profile'        => $profile,
            'projects'       => $db->fetchAll(
                "SELECT p.id, p.name, p.description, p.status, p.color, p.created_at, pm.role
                   FROM project_members pm
                   JOIN projects p ON p.id = pm.project_id
                  WHERE pm.user_id = ?
                  ORDER BY p.created_at",
                [$uid]
            ),
            'tasks'          => $db->fetchAll(
                "SELECT t.id, t.project_id, t.title, t.description, t.status_id, t.priority,
                        t.due_date, t.start_date, t.created_at, t.updated_at,
                        CASE WHEN t.created_by = ? THEN 1 ELSE 0 END AS authored_by_subject,
                        CASE WHEN ta.user_id    = ? THEN 1 ELSE 0 END AS assigned_to_subject
                   FROM tasks t
                   LEFT JOIN task_assignees ta ON ta.task_id = t.id AND ta.user_id = ?
                  WHERE t.created_by = ? OR ta.user_id = ?
                  ORDER BY t.created_at",
                [$uid, $uid, $uid, $uid, $uid]
            ),
            'comments'       => $db->fetchAll(
                "SELECT id, task_id, content, is_edited, created_at, updated_at
                   FROM comments
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
            'ai_messages'    => self::trySelect($db,
                "SELECT id, project_id, role, content, created_at FROM ai_messages WHERE user_id = ? ORDER BY created_at",
                [$uid]
            ),
            'notifications'  => $db->fetchAll(
                "SELECT id, title, message, type, is_read, related_type, related_id, created_at
                   FROM notifications
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
            'activity_log'   => $db->fetchAll(
                "SELECT id, project_id, task_id, action, description, old_value, new_value, created_at
                   FROM activity_log
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
            'security_events'=> self::trySelect($db,
                "SELECT id, event_type, severity, ip, details, created_at
                   FROM security_events
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
            'webauthn'       => self::trySelect($db,
                "SELECT id, name, sign_count, created_at, last_used_at
                   FROM webauthn_credentials
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
            // No login_attempts section: the table is keyed on (ip, attempted_at)
            // with no user_id column — it backs IP-based login rate limiting,
            // not per-user history. Per-user authentication events live in
            // security_events (login_success, login_failure, mfa_failure, …)
            // and are already covered by the security_events section above.
            'invitations'    => $db->fetchAll(
                "SELECT i.id, i.group_id, g.name AS group_name, i.group_role, i.created_at, i.expires_at, i.used_at
                   FROM invitations i
                   LEFT JOIN groups g ON g.id = i.group_id
                  WHERE i.email = ?
                  ORDER BY i.created_at",
                [$email]
            ),
            'attachments'    => $db->fetchAll(
                "SELECT id, task_id, filename, original_name, file_size, mime_type, created_at
                   FROM attachments
                  WHERE user_id = ?
                  ORDER BY created_at",
                [$uid]
            ),
        ];

        // Provider Edition membership data — only present when the
        // `groups` table exists. Each group the subject belongs to.
        $sections['group_memberships'] = self::trySelect($db,
            "SELECT g.id AS group_id, g.name AS group_name, gm.role
               FROM group_members gm
               JOIN groups g ON g.id = gm.group_id
              WHERE gm.user_id = ?
              ORDER BY g.name",
            [$uid]
        );

        return $sections;
    }

    /**
     * Helper to query optional tables (Provider Edition tables, plugin
     * tables) that may not exist on every install. Returns an empty
     * array on failure instead of throwing.
     */
    private static function trySelect(Database $db, string $sql, array $params): array
    {
        try { return $db->fetchAll($sql, $params); }
        catch (\Throwable) { return []; }
    }

    private static function addAttachmentFiles(\ZipArchive $zip, Database $db, int $userId): void
    {
        $rows = $db->fetchAll(
            'SELECT filename, original_name FROM attachments WHERE user_id = ? ORDER BY id',
            [$userId]
        );
        $base = defined('UPLOAD_PATH') ? UPLOAD_PATH : (BASE_PATH . '/data/uploads');
        foreach ($rows as $r) {
            $path = $base . '/' . basename((string)$r['filename']);
            if (!is_file($path)) continue;
            // Use the original filename inside the ZIP, but prefix with
            // the storage filename so duplicates with the same original
            // name do not clobber each other.
            $entry = 'attachments/' . basename((string)$r['filename']) . '__' . basename((string)$r['original_name']);
            $zip->addFile($path, $entry);
        }
    }

    private static function composeReadme(array $user, bool $isAdminTriggered): string
    {
        $intro = trim((string)Helper::getSetting('gdpr_export_introduction_text', ''));
        $appName = Helper::getSetting('app_name', 'Plenvo');
        $now = date('Y-m-d H:i');
        $username = (string)($user['username'] ?? '');
        $email    = (string)($user['email']    ?? '');
        $trigger  = $isAdminTriggered ? Lang::get('gdpr_readme_triggered_admin') : Lang::get('gdpr_readme_triggered_self');

        $lines = [
            sprintf(Lang::get('gdpr_readme_heading'), $appName),
            str_repeat('=', 60),
            '',
            sprintf(Lang::get('gdpr_readme_for'), $username, $email),
            sprintf(Lang::get('gdpr_readme_generated'), $now),
            sprintf(Lang::get('gdpr_readme_trigger'), $trigger),
            '',
        ];
        if ($intro !== '') {
            $lines[] = $intro;
            $lines[] = '';
        }
        $lines[] = Lang::get('gdpr_readme_contents_heading');
        $lines[] = str_repeat('-', 40);
        $lines[] = Lang::get('gdpr_readme_contents');
        $lines[] = '';
        $lines[] = Lang::get('gdpr_readme_rights_heading');
        $lines[] = str_repeat('-', 40);
        $lines[] = Lang::get('gdpr_readme_rights');
        return implode("\n", $lines) . "\n";
    }
}
