<?php
declare(strict_types=1);

defined('BASE_PATH') || exit;

/**
 * Public (no-login) side of the scheduling-poll plugin: viewing a poll via its
 * share link and casting / updating a vote.
 *
 * Authenticated users vote with their account (name pulled from their profile,
 * one response per user). Anonymous visitors enter a name; a per-poll cookie
 * holding the response's `edit_token` lets them come back and change it later.
 */
class PublicPollController
{
    private const MAX_NAME_LEN    = 80;
    private const MAX_COMMENT_LEN = 500;
    /** Per-IP flood guard for the public vote endpoint: at most RL_MAX_NEW
     *  *fresh* responses may be created from one client IP within RL_WINDOW
     *  seconds. Updating an existing response and logged-in voters are never
     *  throttled (a logged-in user has at most one response per poll anyway). */
    private const RL_WINDOW       = 600;
    private const RL_MAX_NEW      = 30;

    public static function show(string $token): void
    {
        $poll = MeetingPollController::loadPollByToken($token);
        if (!$poll) { self::notFound(); return; }

        $options   = MeetingPollController::loadOptions((int)$poll['id']);
        $responses = MeetingPollController::loadResponses((int)$poll['id']);
        $stats     = MeetingPollController::tally($options, $responses);
        $mine      = self::findOwnResponse($poll);
        $owner     = Database::getInstance()->fetch('SELECT username FROM users WHERE id = ?', [(int)$poll['owner_id']]);
        $invite    = self::inviteFromRequest($poll);

        // Pre-fill the form: an existing response wins; otherwise, if the visitor
        // arrived through an invitation link, use the invited name + e-mail.
        $prefillName  = (string)($mine['name']  ?? ($invite['name']  ?? ''));
        $prefillEmail = (string)($mine['email'] ?? ($invite['email'] ?? ''));
        // When the address comes from the invitation (and the person has not
        // responded yet), keep it fixed so the response is matched to the invite.
        $emailLocked  = !$mine && $invite && $prefillEmail !== '';

        MeetingPollPlugin::render('public', [
            'poll'         => $poll,
            'options'      => $options,
            'responses'    => $responses,
            'stats'        => $stats,
            'mine'         => $mine,
            'organizer'    => $owner['username'] ?? '',
            'prefillName'  => $prefillName,
            'prefillEmail' => $prefillEmail,
            'emailLocked'  => (bool)$emailLocked,
            'inviteToken'  => $invite ? (string)$invite['invite_token'] : '',
            'closed'       => MeetingPollController::pollIsClosed($poll),
            'isOwner'      => Auth::check() && (int)Auth::id() === (int)$poll['owner_id'],
            'flashOk'      => Helper::flash('mp_ok'),
            'flashErr'     => Helper::flash('mp_err'),
        ], null);
    }

    public static function vote(string $token): void
    {
        $poll = MeetingPollController::loadPollByToken($token);
        if (!$poll) { self::notFound(); return; }
        if (!Helper::isPost()) Helper::redirect('/poll/' . $token);
        Helper::verifyCsrf();

        if (MeetingPollController::pollIsClosed($poll)) {
            Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_closed')]);
        }

        $pollId  = (int)$poll['id'];
        $options = MeetingPollController::loadOptions($pollId);
        if (empty($options)) Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_no_options')]);

        $db = Database::getInstance();

        // ── Identify / build the response row ───────────────────────────────
        $isLoggedIn = Auth::check();
        $existing   = self::findOwnResponse($poll);

        // Cap fresh responses per poll. Existing participants can always update theirs;
        // this only stops a public link from being used to flood the table / owner mailbox.
        if (!$existing) {
            $count = (int)($db->fetch('SELECT COUNT(*) AS c FROM mp_responses WHERE poll_id = ?', [$pollId])['c'] ?? 0);
            if ($count >= MeetingPollController::MAX_RESPONSES) {
                Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_full')]);
            }
            // Per-IP rate limit for *new* anonymous responses, so the public link
            // cannot be scripted to flood the table or the owner's mailbox.
            // Recorded before name validation, so invalid payloads still count.
            if (!$isLoggedIn && self::newResponseRateLimited()) {
                Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_rate_limited')]);
            }
        }

        if ($isLoggedIn) {
            $user    = Auth::user();
            $name    = trim((string)($user['username'] ?? ''));
            $email   = trim((string)($user['email'] ?? ''));
            $userId  = (int)Auth::id();
        } else {
            $name   = trim((string)Helper::post('name', ''));
            $email  = trim((string)Helper::post('email', ''));
            $userId = null;
            // If they came through an invitation link, fall back to the invited
            // address/name so the response is tied to the invite.
            $invite = self::inviteFromRequest($poll);
            if ($invite) {
                if ($email === '') $email = trim((string)$invite['email']);
                if ($name  === '') $name  = trim((string)$invite['name']);
            }
            if ($name === '') Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_name_required')]);
            if (mb_strlen($name) > self::MAX_NAME_LEN) $name = mb_substr($name, 0, self::MAX_NAME_LEN);
            if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
                Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('mp_err_email')]);
            }
        }
        $comment = trim((string)Helper::post('comment', ''));
        if (mb_strlen($comment) > self::MAX_COMMENT_LEN) $comment = mb_substr($comment, 0, self::MAX_COMMENT_LEN);

        // ── Collect the votes (default "no", clamp "maybe" when disabled) ───
        $allowMaybe = (int)$poll['allow_maybe'] === 1;
        $picked     = [];
        foreach ($options as $o) {
            $oid = (int)$o['id'];
            $v   = (string)Helper::post('vote_' . $oid, 'no');
            if (!in_array($v, MeetingPollController::VOTE_VALUES, true)) $v = 'no';
            if ($v === 'maybe' && !$allowMaybe) $v = 'no';
            $picked[$oid] = $v;
        }

        $db->beginTransaction();
        try {
            if ($existing) {
                $responseId = (int)$existing['id'];
                $db->execute(
                    'UPDATE mp_responses SET name=?, email=?, comment=?, updated_at=CURRENT_TIMESTAMP WHERE id=?',
                    [$name, $email, $comment, $responseId]
                );
                $editToken = (string)$existing['edit_token'];
                $db->execute('DELETE FROM mp_votes WHERE response_id = ?', [$responseId]);
            } else {
                $editToken  = bin2hex(random_bytes(16));
                $responseId = (int)$db->insert(
                    'INSERT INTO mp_responses (poll_id, user_id, name, email, comment, edit_token) VALUES (?,?,?,?,?,?)',
                    [$pollId, $userId, $name, $email, $comment, $editToken]
                );
            }
            foreach ($picked as $oid => $v) {
                $db->execute('INSERT INTO mp_votes (response_id, option_id, value) VALUES (?,?,?)', [$responseId, $oid, $v]);
            }
            $db->commit();
        } catch (\Throwable $e) {
            $db->rollback();
            error_log('[meetingpoll] vote save failed: ' . $e->getMessage());
            Helper::redirect('/poll/' . $token, ['mp_err' => Lang::get('error')]);
        }

        // Remember anonymous voters so they can edit later.
        if (!$isLoggedIn) {
            self::setEditCookie($pollId, $editToken);
        }

        if (!$existing) {
            MeetingPollController::notifyOwnerNewResponse($poll, $name);
        }
        Helper::redirect('/poll/' . $token, ['mp_ok' => Lang::get('mp_vote_saved')]);
    }

    // ── Helpers ─────────────────────────────────────────────────────────────

    /** The current visitor's existing response for this poll, or null. */
    public static function findOwnResponse(array $poll): ?array
    {
        $db = Database::getInstance();
        if (Auth::check()) {
            $row = $db->fetch('SELECT * FROM mp_responses WHERE poll_id = ? AND user_id = ?', [(int)$poll['id'], (int)Auth::id()]);
            if ($row) { return self::withVotes($row); }
        }
        $cookie = (string)($_COOKIE['mp_r_' . (int)$poll['id']] ?? '');
        if ($cookie !== '' && preg_match('/^[a-f0-9]{32}$/', $cookie)) {
            $row = $db->fetch('SELECT * FROM mp_responses WHERE poll_id = ? AND edit_token = ? AND user_id IS NULL', [(int)$poll['id'], $cookie]);
            if ($row) { return self::withVotes($row); }
        }
        return null;
    }

    /** The invitation referenced by the `i` parameter (GET or POST), or null. */
    private static function inviteFromRequest(array $poll): ?array
    {
        $tok = (string)(Helper::get('i', '') ?: Helper::post('i', '') ?: Helper::post('invite_token', ''));
        if (!preg_match('/^[a-f0-9]{32}$/', $tok)) return null;
        $row = Database::getInstance()->fetch(
            'SELECT * FROM mp_invites WHERE poll_id = ? AND invite_token = ?', [(int)$poll['id'], $tok]
        );
        return $row ?: null;
    }

    private static function withVotes(array $row): array
    {
        $row['votes'] = [];
        foreach (Database::getInstance()->fetchAll('SELECT option_id, value FROM mp_votes WHERE response_id = ?', [(int)$row['id']]) as $v) {
            $row['votes'][(int)$v['option_id']] = $v['value'];
        }
        return $row;
    }

    private static function setEditCookie(int $pollId, string $editToken): void
    {
        $base   = defined('APP_BASE') ? APP_BASE : '';
        $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
               || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
        setcookie('mp_r_' . $pollId, $editToken, [
            'expires'  => time() + 60 * 86400,
            'path'     => ($base !== '' ? $base : '') . '/poll/',
            'httponly' => true,
            'samesite' => 'Lax',
            'secure'   => $secure,
        ]);
    }

    /**
     * Sliding-window per-IP limiter for *creating* new responses. Returns true
     * when the caller has already hit the cap (and the request should be
     * rejected); otherwise records this attempt and returns false. Backed by a
     * small temp file keyed on the resolved client IP (honours
     * `trust_proxy_headers`), mirroring the contact-form limiter.
     */
    private static function newResponseRateLimited(): bool
    {
        $ip   = Helper::clientIp() ?? '0.0.0.0';
        $file = sys_get_temp_dir() . '/plenvo_mp_vote_' . md5($ip) . '.tmp';
        $now  = time();
        $hits = [];
        if (is_file($file)) {
            foreach (explode("\n", (string)@file_get_contents($file)) as $line) {
                $t = (int)trim($line);
                if ($t > 0 && $now - $t < self::RL_WINDOW) $hits[] = $t;
            }
        }
        if (count($hits) >= self::RL_MAX_NEW) return true;
        $hits[] = $now;
        @file_put_contents($file, implode("\n", $hits), LOCK_EX);
        return false;
    }

    private static function notFound(): void
    {
        http_response_code(404);
        MeetingPollPlugin::render('not_found', ['pageTitle' => Lang::get('not_found')], null);
    }
}
