<?php
/**
 * NIS2 Agile - Supplier Portal Controller (Fase 3)
 * ----------------------------------------------------------------------------
 * Portale fornitore ad accesso PASSWORDLESS (OTP 8 cifre + magic-link), con
 * autenticazione COMPLETAMENTE SEPARATA dagli utenti interni:
 *   supplier_users / supplier_otp / supplier_sessions (MAI users/active_sessions).
 *
 * Sicurezza (CRITICI design §3 + UX_MINI_SPEC §3):
 *  1) JWT con SUPPLIER_JWT_SECRET dedicato, aud="supplier-portal", claim sp_uid+
 *     supplier_id+org_id, MAI user_id. requireSupplierSession() verifica il jti in
 *     supplier_sessions (revocabile), NON tocca users e NON riusa verifyJWT().
 *  2) OTP 8 cifre, SHA-256, 15min, max 5 tentativi con lockout PERSISTENTE,
 *     invalida OTP precedenti, hash_equals, rate-limit email+IP.
 *  3) Magic-link 32 byte hashed, single-use, consumo solo su verify esplicito.
 *  4) request-otp risposta OPACA (anti-enumerazione).
 *  5) OTP via EmailService::sendViaTemplate (/api/emails/send, NON send-raw -> fuori da email_log).
 *  6) Ogni endpoint valida campaign.supplier_id == session.supplier_id (no IDOR).
 */

require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/EmailService.php';
require_once APP_PATH . '/services/RateLimitService.php';
require_once APP_PATH . '/services/AuditService.php';

class SupplierPortalController extends BaseController
{
    private const SESSION_TTL_SECONDS = 4 * 3600;
    private const OTP_TTL_SECONDS     = 15 * 60;
    private const OTP_DIGITS          = 8;
    private const OTP_MAX_ATTEMPTS    = 5;
    private const OTP_LOCKOUT_SECONDS = 15 * 60;
    private const RL_REQUEST = [['max' => 3, 'window_seconds' => 600], ['max' => 8, 'window_seconds' => 3600]];
    private const RL_VERIFY  = [['max' => 10, 'window_seconds' => 60], ['max' => 40, 'window_seconds' => 3600]];

    private ?array $supplierSession = null;

    // ════════════════ AUTH OTP / MAGIC-LINK (NO AUTH interna) ════════════════

    /** POST /api/supplier-portal/request-otp  Body: {email} — risposta opaca. */
    public function requestOtp(): void
    {
        $email = strtolower(trim((string) $this->getParam('email', '')));
        $ip    = $this->getClientIPLocal();
        $opaqueMsg = 'Se l\'email e\' registrata, riceverai il codice di accesso.';

        if ($email === '' || !$this->validateEmail($email)) {
            $this->jsonSuccess(['delivered' => true], $opaqueMsg);
        }

        RateLimitService::check('sp_req_email:' . $email, self::RL_REQUEST);
        RateLimitService::check('sp_req_ip:' . $ip, self::RL_REQUEST);
        RateLimitService::increment('sp_req_email:' . $email);
        RateLimitService::increment('sp_req_ip:' . $ip);

        $su = Database::fetchOne(
            'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name
               FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id
              WHERE su.email = ? AND su.is_active = 1 AND s.deleted_at IS NULL
              ORDER BY su.last_login_at DESC, su.id DESC LIMIT 1',
            [$email]
        );
        if (!$su) {
            $this->jsonSuccess(['delivered' => true], $opaqueMsg);
        }

        // Invalida OTP/magic precedenti del referente.
        Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE supplier_user_id = ? AND consumed_at IS NULL',
            [(int) $su['id']]);

        $otpCode    = $this->generateNumericOtp(self::OTP_DIGITS);
        $magicToken = 'sp_' . bin2hex(random_bytes(32));
        Database::insert('supplier_otp', [
            'supplier_user_id' => (int) $su['id'],
            'otp_hash'         => hash('sha256', $otpCode),
            'magic_token_hash' => hash('sha256', $magicToken),
            'purpose'          => 'login',
            'attempts'         => 0,
            'max_attempts'     => self::OTP_MAX_ATTEMPTS,
            'expires_at'       => date('Y-m-d H:i:s', time() + self::OTP_TTL_SECONDS),
            'ip_created'       => substr($ip, 0, 45),
        ]);

        $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [(int) $su['organization_id']]);
        $committenteName = $committente['name'] ?? 'il committente';
        $magicUrl = rtrim(APP_URL, '/') . '/supplier-portal.html?magic=' . urlencode($magicToken);

        try {
            (new EmailService())->sendViaTemplate($su['email'], 'supplier_otp', [
                'committente' => $committenteName,
                'otp'         => trim(chunk_split($otpCode, 3, ' ')),
                'magic_url'   => $magicUrl,
                'full_name'   => $su['full_name'] ?? '',
                'ttl_minutes' => (string) (int) (self::OTP_TTL_SECONDS / 60),
            ], $committenteName);
        } catch (Throwable $e) {
            error_log('[SUPPLIER_PORTAL] invio OTP fallito: ' . $e->getMessage());
        }

        $this->auditSupplier((int) $su['organization_id'], 'supplier_otp_requested', 'supplier_user',
            (int) $su['id'], ['supplier_id' => (int) $su['supplier_id']]);
        $this->jsonSuccess(['delivered' => true], $opaqueMsg);
    }

    /** POST /api/supplier-portal/verify-otp  Body: {email,code} | {magic_token}. */
    public function verifyOtp(): void
    {
        $ip = $this->getClientIPLocal();
        RateLimitService::check('sp_verify_ip:' . $ip, self::RL_VERIFY);
        RateLimitService::increment('sp_verify_ip:' . $ip);

        $magicToken = trim((string) $this->getParam('magic_token', ''));
        if ($magicToken !== '') {
            $otpRow = $this->resolveOtpByMagic($magicToken);
        } else {
            $email = strtolower(trim((string) $this->getParam('email', '')));
            $code  = preg_replace('/\D+/', '', (string) $this->getParam('code', ''));
            if ($email === '' || $code === '') {
                $this->jsonError('Inserisci email e codice di accesso.', 422, 'MISSING_CREDENTIALS');
            }
            $otpRow = $this->resolveOtpByCode($email, $code);
        }

        $su = Database::fetchOne(
            'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name
               FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id
              WHERE su.id = ? AND su.is_active = 1 AND s.deleted_at IS NULL',
            [(int) $otpRow['supplier_user_id']]
        );
        if (!$su) {
            $this->jsonError('Accesso non disponibile. Contatta il committente.', 403, 'SUPPLIER_DISABLED');
        }

        // Consumo single-use atomico.
        $consumed = Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE id = ? AND consumed_at IS NULL',
            [(int) $otpRow['id']]);
        if ($consumed->rowCount() === 0) {
            $this->jsonError('Questo codice e\' gia\' stato utilizzato. Richiedi un nuovo accesso.', 409, 'ALREADY_USED');
        }

        $jti = $this->uuidv4();
        $expiresAt = date('Y-m-d H:i:s', time() + self::SESSION_TTL_SECONDS);
        Database::insert('supplier_sessions', [
            'supplier_user_id' => (int) $su['id'],
            'organization_id'  => (int) $su['organization_id'],
            'jti'              => $jti,
            'ip_address'       => substr($ip, 0, 45),
            'user_agent'       => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500),
            'expires_at'       => $expiresAt,
        ]);
        Database::update('supplier_users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $su['id']]);

        $jwt = $this->issueSupplierJwt([
            'sp_uid' => (int) $su['id'], 'supplier_id' => (int) $su['supplier_id'],
            'org_id' => (int) $su['organization_id'], 'jti' => $jti,
        ]);
        $this->auditSupplier((int) $su['organization_id'], 'supplier_login', 'supplier_user', (int) $su['id'],
            ['supplier_id' => (int) $su['supplier_id'], 'method' => $magicToken !== '' ? 'magic' : 'otp']);

        $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [(int) $su['supplier_id']]);
        $this->jsonSuccess([
            'token' => $jwt, 'expires_at' => $expiresAt,
            'supplier' => ['id' => (int) $su['supplier_id'], 'name' => $supplier['name'] ?? ''],
            'user' => ['id' => (int) $su['id'], 'email' => $su['email'], 'full_name' => $su['full_name']],
        ], 'Accesso effettuato');
    }

    // ════════════════ ENDPOINT AUTENTICATI (sessione fornitore) ════════════════

    /** GET /api/supplier-portal/me */
    public function me(): void
    {
        $this->requireSupplierSession();
        $supplierId = $this->supplierSession['supplier_id'];
        $orgId      = $this->supplierSession['org_id'];

        $supplier = Database::fetchOne(
            'SELECT id, name, service_type, criticality FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL',
            [$supplierId, $orgId]
        );
        if (!$supplier) {
            $this->jsonError('Fornitore non disponibile', 404, 'SUPPLIER_NOT_FOUND');
        }
        $campaigns = Database::fetchAll(
            "SELECT id, template_id, template_version, status, due_at, sent_at, completed_at,
                    expires_at, show_score_to_supplier, language,
                    CASE WHEN show_score_to_supplier = 1 THEN score ELSE NULL END AS score
               FROM questionnaire_campaigns
              WHERE supplier_id = ? AND organization_id = ?
                    AND status IN ('sent','in_progress','completed','expired')
              ORDER BY (status IN ('sent','in_progress')) DESC, (due_at IS NULL), due_at ASC, sent_at DESC",
            [$supplierId, $orgId]
        );
        $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$orgId]);

        $this->jsonSuccess([
            'supplier' => [
                'id' => (int) $supplier['id'], 'name' => $supplier['name'],
                'service_type' => $supplier['service_type'], 'criticality' => $supplier['criticality'],
            ],
            'committente' => ['name' => $committente['name'] ?? ''],
            'user' => [
                'id' => $this->supplierSession['sp_uid'], 'email' => $this->supplierSession['email'],
                'full_name' => $this->supplierSession['full_name'],
            ],
            'campaigns' => array_map(static fn(array $c): array => [
                'id' => (int) $c['id'], 'status' => $c['status'], 'due_at' => $c['due_at'],
                'sent_at' => $c['sent_at'], 'completed_at' => $c['completed_at'], 'expires_at' => $c['expires_at'],
                'language' => $c['language'], 'show_score' => (bool) $c['show_score_to_supplier'],
                'score' => $c['score'] !== null ? (int) $c['score'] : null,
            ], $campaigns),
        ]);
    }

    /** GET /api/supplier-portal/{id} — template+domande della campagna (+ risposte salvate). */
    public function getQuestionnaire(int $id): void
    {
        $this->requireSupplierSession();
        $campaign = $this->loadOwnedCampaign($id);
        $supplier = Database::fetchOne('SELECT name, criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]);
        $highCrit = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true);

        $questions = $this->loadCampaignQuestions($campaign, $highCrit);
        $answers = Database::fetchAll(
            'SELECT question_id, question_code, answer_value, answer_text, file_ref
               FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?',
            [$campaign['id'], $campaign['organization_id']]
        );
        $answersByQid = [];
        foreach ($answers as $a) {
            $key = $a['question_id'] !== null ? (string) (int) $a['question_id'] : ('code:' . $a['question_code']);
            $answersByQid[$key] = [
                'value'    => $a['answer_value'] !== null ? json_decode($a['answer_value'], true) : null,
                'text'     => $a['answer_text'], 'file_ref' => $a['file_ref'],
            ];
        }
        $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$campaign['organization_id']]);

        $this->jsonSuccess([
            'campaign' => [
                'id' => (int) $campaign['id'], 'status' => $campaign['status'], 'due_at' => $campaign['due_at'],
                'expires_at' => $campaign['expires_at'], 'language' => $campaign['language'],
                'show_score' => (bool) $campaign['show_score_to_supplier'],
            ],
            'committente' => ['name' => $committente['name'] ?? ''],
            'supplier' => ['name' => $supplier['name'] ?? ''],
            'questions' => $questions, 'answers' => $answersByQid,
        ]);
    }

    /** PATCH /api/supplier-portal/{id}/answers — autosave per-domanda. */
    public function saveAnswers(int $id): void
    {
        $this->requireSupplierSession();
        $campaign = $this->loadOwnedCampaign($id);
        if ($campaign['status'] === 'completed') {
            $this->jsonError('Questionario gia\' inviato: non e\' piu\' modificabile.', 409, 'ALREADY_SUBMITTED');
        }
        $this->assertCampaignOpen($campaign);

        $body = $this->getJsonBody();
        $answers = is_array($body['answers'] ?? null) ? $body['answers'] : [];
        if (empty($answers)) {
            $this->jsonError('Nessuna risposta da salvare', 422, 'NO_ANSWERS');
        }
        $validQ = $this->validQuestionIds($campaign);
        $saved = 0;
        foreach ($answers as $a) {
            if (!is_array($a)) continue;
            $qid  = isset($a['question_id']) ? (int) $a['question_id'] : null;
            $code = isset($a['question_code']) ? (string) $a['question_code'] : null;
            if ($qid !== null && !isset($validQ[$qid])) continue;
            if ($qid === null && $code === null) continue;
            $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null;
            $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code,
                $valueJson, isset($a['text']) ? (string) $a['text'] : null,
                isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']);
            $saved++;
        }
        if ($campaign['status'] === 'sent') {
            Database::query("UPDATE questionnaire_campaigns SET status = 'in_progress' WHERE id = ? AND organization_id = ? AND status = 'sent'",
                [(int) $campaign['id'], (int) $campaign['organization_id']]);
        }
        $this->jsonSuccess(['saved' => $saved, 'status' => 'in_progress', 'saved_at' => date('c')], 'Bozza salvata');
    }

    /** POST /api/supplier-portal/{id}/submit — invio definitivo + scoring. */
    public function submitQuestionnaire(int $id): void
    {
        $this->requireSupplierSession();
        $campaign = $this->loadOwnedCampaign($id);
        if ($campaign['status'] === 'completed') {
            $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED');
        }
        $this->assertCampaignOpen($campaign);

        $body = $this->getJsonBody();
        $answers = is_array($body['answers'] ?? null) ? $body['answers'] : [];
        $supplier = Database::fetchOne('SELECT criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]);
        $highCrit  = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true);
        $questions = $this->loadCampaignQuestions($campaign, $highCrit);

        Database::beginTransaction();
        try {
            $validQ = $this->validQuestionIds($campaign);
            foreach ($answers as $a) {
                if (!is_array($a)) continue;
                $qid  = isset($a['question_id']) ? (int) $a['question_id'] : null;
                $code = isset($a['question_code']) ? (string) $a['question_code'] : null;
                if ($qid !== null && !isset($validQ[$qid])) continue;
                if ($qid === null && $code === null) continue;
                $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null;
                $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code,
                    $valueJson, isset($a['text']) ? (string) $a['text'] : null,
                    isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']);
            }
            $stored = Database::fetchAll(
                'SELECT question_id, question_code, answer_value, answer_text FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?',
                [(int) $campaign['id'], (int) $campaign['organization_id']]
            );
            $storedByQid = [];
            foreach ($stored as $s) {
                if ($s['question_id'] !== null) $storedByQid[(int) $s['question_id']] = $s;
                elseif ($s['question_code'] !== null) $storedByQid['code:' . $s['question_code']] = $s;
            }
            $missing = [];
            foreach ($questions as $q) {
                if (!$q['is_required']) continue;
                $stKey = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']);
                if (!$this->answerIsPresent($storedByQid[$stKey] ?? null)) $missing[] = $q['code'] ?? (string) $q['id'];
            }
            if (!empty($missing)) {
                Database::rollback();
                $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]);
            }
            [$score, $vulnerabilities] = $this->computeScore($questions, $storedByQid, $highCrit);
            $riskLevel = $this->riskLevelFromScore($score);
            $upd = Database::query(
                "UPDATE questionnaire_campaigns SET status='completed', score=?, risk_level=?, answers=?, completed_at=NOW()
                  WHERE id = ? AND organization_id = ? AND status <> 'completed'",
                [$score, $riskLevel, json_encode(['vulnerabilities' => $vulnerabilities], JSON_UNESCAPED_UNICODE),
                 (int) $campaign['id'], (int) $campaign['organization_id']]
            );
            if ($upd->rowCount() === 0) {
                Database::rollback();
                $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED');
            }
            Database::query(
                'UPDATE suppliers SET risk_score = ?, security_requirements_met = ?, last_assessment_date = CURDATE() WHERE id = ? AND organization_id = ?',
                [$score, $score >= 70 ? 1 : 0, (int) $campaign['supplier_id'], (int) $campaign['organization_id']]
            );
            Database::commit();
        } catch (Throwable $e) {
            if (Database::getInstance()->inTransaction()) Database::rollback();
            throw $e;
        }

        $this->auditSupplier((int) $campaign['organization_id'], 'supplier_questionnaire_submitted', 'questionnaire_campaign',
            (int) $campaign['id'], ['supplier_id' => (int) $campaign['supplier_id'], 'score' => $score, 'risk_level' => $riskLevel]);

        $payload = ['completed_at' => date('c'), 'show_score' => (bool) $campaign['show_score_to_supplier']];
        if ($campaign['show_score_to_supplier']) { $payload['score'] = $score; $payload['risk_level'] = $riskLevel; }
        $this->jsonSuccess($payload, 'Questionario inviato. Grazie.');
    }

    // ════════════════ AUTH SEPARATA ════════════════

    private function requireSupplierSession(): void
    {
        $token = $this->getBearerToken();
        if (!$token) $this->jsonError('Sessione mancante. Richiedi un nuovo accesso.', 401, 'MISSING_TOKEN');
        $payload = $this->verifySupplierJwt($token);
        if (!$payload) $this->jsonError('Sessione non valida o scaduta. Richiedi un nuovo accesso.', 401, 'INVALID_TOKEN');

        $jti = $payload['jti'] ?? null;
        $spUid = isset($payload['sp_uid']) ? (int) $payload['sp_uid'] : 0;
        if (!$jti || $spUid <= 0) $this->jsonError('Sessione non valida.', 401, 'INVALID_SESSION');

        $session = Database::fetchOne(
            'SELECT ss.id, ss.supplier_user_id, ss.organization_id, su.supplier_id, su.email, su.full_name, su.is_active
               FROM supplier_sessions ss JOIN supplier_users su ON su.id = ss.supplier_user_id
              WHERE ss.jti = ? AND ss.supplier_user_id = ? AND ss.revoked_at IS NULL AND ss.expires_at > NOW()',
            [$jti, $spUid]
        );
        if (!$session || (int) $session['is_active'] !== 1) {
            $this->jsonError('Sessione non valida o revocata. Richiedi un nuovo accesso.', 401, 'SESSION_REVOKED');
        }
        if ((int) $payload['supplier_id'] !== (int) $session['supplier_id'] || (int) $payload['org_id'] !== (int) $session['organization_id']) {
            $this->jsonError('Sessione incoerente. Richiedi un nuovo accesso.', 401, 'SESSION_MISMATCH');
        }
        $this->supplierSession = [
            'jti' => $jti, 'sp_uid' => (int) $session['supplier_user_id'],
            'supplier_id' => (int) $session['supplier_id'], 'org_id' => (int) $session['organization_id'],
            'email' => $session['email'], 'full_name' => $session['full_name'],
        ];
    }

    private function issueSupplierJwt(array $claims): string
    {
        $header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
        $payload = json_encode(array_merge([
            'aud' => 'supplier-portal', 'iss' => APP_URL, 'iat' => time(), 'exp' => time() + self::SESSION_TTL_SECONDS,
        ], $claims), JSON_UNESCAPED_UNICODE);
        $h = $this->base64UrlEncode($header);
        $p = $this->base64UrlEncode($payload);
        $s = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true));
        return "$h.$p.$s";
    }

    private function verifySupplierJwt(string $token): ?array
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) return null;
        [$h, $p, $sig] = $parts;
        $expected = hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true);
        if (!hash_equals($expected, $this->base64UrlDecode($sig))) return null;
        $payload = json_decode($this->base64UrlDecode($p), true);
        if (!is_array($payload)) return null;
        if (($payload['aud'] ?? null) !== 'supplier-portal') return null;
        if (isset($payload['exp']) && (int) $payload['exp'] < time()) return null;
        if (isset($payload['user_id'])) return null; // token interno → rifiuto netto
        return $payload;
    }

    private function supplierJwtSecret(): string
    {
        if (defined('SUPPLIER_JWT_SECRET') && SUPPLIER_JWT_SECRET !== '') return SUPPLIER_JWT_SECRET;
        return hash_hmac('sha256', 'supplier-portal-v1', JWT_SECRET);
    }

    // ════════════════ HELPER OTP ════════════════

    private function resolveOtpByCode(string $email, string $code): array
    {
        $su = Database::fetchOne('SELECT id FROM supplier_users WHERE email = ? AND is_active = 1 ORDER BY id DESC LIMIT 1', [$email]);
        if (!$su) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID');

        $otp = Database::fetchOne(
            "SELECT * FROM supplier_otp WHERE supplier_user_id = ? AND purpose = 'login' AND otp_hash IS NOT NULL AND consumed_at IS NULL ORDER BY id DESC LIMIT 1",
            [(int) $su['id']]
        );
        if (!$otp) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID');
        $this->assertOtpUsable($otp);

        if (!hash_equals((string) $otp['otp_hash'], hash('sha256', $code))) {
            $attempts = (int) $otp['attempts'] + 1;
            $fields = ['attempts' => $attempts];
            $remaining = max(0, (int) $otp['max_attempts'] - $attempts);
            if ($attempts >= (int) $otp['max_attempts']) {
                $fields['locked_until'] = date('Y-m-d H:i:s', time() + self::OTP_LOCKOUT_SECONDS);
            }
            Database::update('supplier_otp', $fields, 'id = ?', [(int) $otp['id']]);
            if ($remaining <= 0) {
                $this->jsonError('Troppi tentativi. Accesso bloccato per ' . (int) (self::OTP_LOCKOUT_SECONDS / 60) . ' minuti.', 429, 'OTP_LOCKED');
            }
            $this->jsonError('Codice errato. Hai ancora ' . $remaining . ' tentativi.', 401, 'OTP_WRONG', ['remaining' => $remaining]);
        }
        return $otp;
    }

    private function resolveOtpByMagic(string $magicToken): array
    {
        if (!preg_match('/^sp_[a-f0-9]{64}$/', $magicToken)) {
            $this->jsonError('Link non valido o scaduto. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID');
        }
        $otp = Database::fetchOne(
            "SELECT * FROM supplier_otp WHERE magic_token_hash = ? AND purpose = 'login' AND consumed_at IS NULL ORDER BY id DESC LIMIT 1",
            [hash('sha256', $magicToken)]
        );
        if (!$otp) $this->jsonError('Link non valido o gia\' utilizzato. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID');
        $this->assertOtpUsable($otp);
        return $otp;
    }

    private function assertOtpUsable(array $otp): void
    {
        if (!empty($otp['locked_until']) && strtotime($otp['locked_until']) > time()) {
            $minutes = max(1, (int) ceil((strtotime($otp['locked_until']) - time()) / 60));
            $this->jsonError('Accesso temporaneamente bloccato. Riprova tra ' . $minutes . ' minuti.', 429, 'OTP_LOCKED');
        }
        if (strtotime((string) $otp['expires_at']) < time()) {
            $this->jsonError('Codice scaduto. Richiedi un nuovo accesso.', 401, 'OTP_EXPIRED');
        }
    }

    private function generateNumericOtp(int $digits): string
    {
        $max = (10 ** $digits) - 1;
        return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT);
    }

    // ════════════════ HELPER CAMPAGNA / SCORING ════════════════

    private function loadOwnedCampaign(int $id): array
    {
        $campaign = Database::fetchOne(
            'SELECT * FROM questionnaire_campaigns WHERE id = ? AND supplier_id = ? AND organization_id = ?',
            [$id, $this->supplierSession['supplier_id'], $this->supplierSession['org_id']]
        );
        if (!$campaign) $this->jsonError('Questionario non trovato', 404, 'CAMPAIGN_NOT_FOUND');
        return $campaign;
    }

    private function assertCampaignOpen(array $campaign): void
    {
        if (in_array($campaign['status'], ['cancelled', 'draft', 'expired'], true)) {
            $this->jsonError('Questionario non disponibile alla compilazione.', 409, 'CAMPAIGN_CLOSED');
        }
        if (!empty($campaign['expires_at']) && strtotime((string) $campaign['expires_at']) < time()) {
            Database::query("UPDATE questionnaire_campaigns SET status='expired' WHERE id = ? AND status NOT IN ('completed','cancelled')", [(int) $campaign['id']]);
            $this->jsonError('Il termine per la compilazione e\' scaduto.', 410, 'CAMPAIGN_EXPIRED');
        }
    }

    private function loadCampaignQuestions(array $campaign, bool $highCrit): array
    {
        $raw = [];
        if ($campaign['template_id'] !== null && $campaign['template_version'] !== null) {
            $snap = Database::fetchOne(
                'SELECT questions_snapshot FROM questionnaire_template_versions WHERE template_id = ? AND version = ? AND organization_id = ?',
                [(int) $campaign['template_id'], $campaign['template_version'], (int) $campaign['organization_id']]
            );
            if ($snap && !empty($snap['questions_snapshot'])) {
                $decoded = json_decode($snap['questions_snapshot'], true);
                if (is_array($decoded)) $raw = $decoded;
            }
        }
        if (empty($raw) && $campaign['template_id'] !== null) {
            $raw = Database::fetchAll(
                'SELECT id, question_code AS code, question_text AS text, question_type AS type, options, weight, is_required, order_index, nis2_ref, vuln_flag, high_criticality_only
                   FROM questionnaire_questions WHERE template_id = ? AND organization_id = ? ORDER BY order_index, id',
                [(int) $campaign['template_id'], (int) $campaign['organization_id']]
            );
        }
        $out = [];
        foreach ($raw as $q) {
            if ((bool) ($q['high_criticality_only'] ?? false) && !$highCrit) continue;
            $opts = $q['options'] ?? null;
            if (is_string($opts)) $opts = json_decode($opts, true);
            $out[] = [
                'id' => isset($q['id']) ? (int) $q['id'] : null,
                'code' => $q['code'] ?? ($q['question_code'] ?? null),
                'text' => (string) ($q['text'] ?? $q['question_text'] ?? ''),
                'type' => (string) ($q['type'] ?? $q['question_type'] ?? 'yes_no_partial'),
                'options' => is_array($opts) ? $opts : null,
                'weight' => (float) ($q['weight'] ?? 1.0),
                'is_required' => (bool) ($q['is_required'] ?? $q['required'] ?? true),
                'nis2_ref' => $q['nis2_ref'] ?? null,
                'vuln_flag' => $q['vuln_flag'] ?? null,
                'help_text' => $q['help_text'] ?? null,
            ];
        }
        return $out;
    }

    private function validQuestionIds(array $campaign): array
    {
        if ($campaign['template_id'] === null) return [];
        $rows = Database::fetchAll('SELECT id FROM questionnaire_questions WHERE template_id = ? AND organization_id = ?',
            [(int) $campaign['template_id'], (int) $campaign['organization_id']]);
        $map = [];
        foreach ($rows as $r) $map[(int) $r['id']] = true;
        return $map;
    }

    private function upsertAnswer(int $campaignId, int $orgId, ?int $questionId, ?string $questionCode, ?string $valueJson, ?string $text, ?string $fileRef, int $answeredBy): void
    {
        if ($questionId !== null) {
            Database::query(
                'INSERT INTO questionnaire_answers (campaign_id, organization_id, question_id, question_code, answer_value, answer_text, file_ref, answered_by, answered_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
                 ON DUPLICATE KEY UPDATE answer_value=VALUES(answer_value), answer_text=VALUES(answer_text), file_ref=VALUES(file_ref), answered_by=VALUES(answered_by), answered_at=NOW()',
                [$campaignId, $orgId, $questionId, $questionCode, $valueJson, $text, $fileRef, $answeredBy]
            );
            return;
        }
        $existing = $questionCode !== null ? Database::fetchOne(
            'SELECT id FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ? AND question_id IS NULL AND question_code = ? LIMIT 1',
            [$campaignId, $orgId, $questionCode]) : null;
        if ($existing) {
            Database::update('questionnaire_answers', [
                'answer_value' => $valueJson, 'answer_text' => $text, 'file_ref' => $fileRef,
                'answered_by' => $answeredBy, 'answered_at' => date('Y-m-d H:i:s'),
            ], 'id = ?', [(int) $existing['id']]);
        } else {
            Database::insert('questionnaire_answers', [
                'campaign_id' => $campaignId, 'organization_id' => $orgId, 'question_id' => null,
                'question_code' => $questionCode, 'answer_value' => $valueJson, 'answer_text' => $text,
                'file_ref' => $fileRef, 'answered_by' => $answeredBy,
            ]);
        }
    }

    private function answerIsPresent(?array $row): bool
    {
        if ($row === null) return false;
        if (!empty($row['answer_text'])) return true;
        if (!empty($row['file_ref'])) return true;
        if ($row['answer_value'] !== null) {
            $decoded = json_decode((string) $row['answer_value'], true);
            if (is_array($decoded)) return count($decoded) > 0;
            return $decoded !== null && $decoded !== '';
        }
        return false;
    }

    private function computeScore(array $questions, array $storedByQid, bool $highCrit): array
    {
        $earned = 0.0; $maxScore = 0.0; $vulns = [];
        $rtoThreshold = $highCrit ? 8 : 24;
        $highExposure = ['privileg', 'amministr', 'critic', 'segret', 'sensibil', 'personal'];

        foreach ($questions as $q) {
            $key = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']);
            $value = $this->decodeAnswerValue($storedByQid[$key] ?? null);
            $weight = max(0.0, (float) $q['weight']);
            switch ($q['type']) {
                case 'yes_no_partial':
                    $maxScore += $weight;
                    $v = is_string($value) ? strtolower($value) : '';
                    $factor = match ($v) { 'yes', 'si', 'sì' => 1.0, 'partial', 'parziale' => 0.5, default => 0.0 };
                    $earned += $weight * $factor;
                    if ($factor < 1.0 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag'];
                    break;
                case 'scale_1_5':
                    $maxScore += $weight;
                    $n = $this->scaleToInt($value);
                    if ($n !== null && $n >= 1 && $n <= 5) {
                        $earned += $weight * (($n - 1) / 4);
                        if ($n <= 2 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag'];
                    } elseif (!empty($q['vuln_flag'])) { $vulns[] = $q['vuln_flag']; }
                    break;
                case 'number':
                    $num = is_numeric($value) ? (float) $value : null;
                    if (($num === null || $num > $rtoThreshold) && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag'];
                    break;
                case 'single_choice':
                case 'multi_choice':
                    $picks = is_array($value) ? $value : ($value !== null ? [$value] : []);
                    foreach ($picks as $pick) {
                        $p = strtolower((string) $pick);
                        foreach ($highExposure as $needle) {
                            if (str_contains($p, $needle)) { if (!empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; break 2; }
                        }
                    }
                    break;
            }
        }
        $score = $maxScore > 0.0 ? (int) round($earned / $maxScore * 100) : 0;
        return [$score, array_values(array_unique($vulns))];
    }

    private function decodeAnswerValue(?array $row)
    {
        if ($row === null) return null;
        if ($row['answer_value'] !== null) {
            $decoded = json_decode((string) $row['answer_value'], true);
            if ($decoded !== null) return $decoded;
        }
        return $row['answer_text'] ?? null;
    }

    private function scaleToInt($value): ?int
    {
        if (is_int($value)) return $value;
        if (is_numeric($value)) return (int) $value;
        if (is_string($value) && preg_match('/^\s*([1-5])/', $value, $m)) return (int) $m[1];
        return null;
    }

    private function riskLevelFromScore(int $score): string
    {
        return $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical'));
    }

    // ════════════════ UTILITY ════════════════

    private function getClientIPLocal(): string
    {
        $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
        if ($xff !== '') {
            $first = trim(explode(',', $xff)[0]);
            if (filter_var($first, FILTER_VALIDATE_IP)) return $first;
        }
        return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    }

    private function uuidv4(): string
    {
        $b = random_bytes(16);
        $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
        $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
    }

    private function auditSupplier(int $orgId, string $action, string $entityType, ?int $entityId, array $details): void
    {
        try {
            AuditService::log($orgId, null, $action, $entityType, $entityId, $details,
                $this->getClientIPLocal(), $_SERVER['HTTP_USER_AGENT'] ?? null,
                AuditService::resolveSeverity($action, $details), null);
        } catch (Throwable $e) {
            error_log('[SUPPLIER_PORTAL] audit fallito: ' . $e->getMessage());
        }
    }
}
