<?php
/**
 * NIS2 Agile — Invite Controller
 *
 * Sistema inviti / licenze a tempo per provisioning automatico B2B.
 * Gli inviti vengono generati da NIS2 Admin (o via API admin),
 * distribuiti dall'e-commerce, usati da lg231 e altri sistemi Agile.
 *
 * Endpoints admin (richiedono JWT super_admin OPPURE API Key con scope admin:licenses):
 *   POST   /api/invites/create          → genera invito
 *   GET    /api/invites/list            → lista inviti
 *   GET    /api/invites/{id}            → dettaglio invito
 *   DELETE /api/invites/{id}            → revoca invito
 *   POST   /api/invites/{id}/regenerate → rigenera token mantenendo config
 *
 * Endpoints pubblici (nessuna auth):
 *   GET    /api/invites/validate?token= → valida invito (preview piano, scadenza)
 *
 * Auth da sistemi esterni (es. mktg-agile):
 *   Header: X-API-Key: nis2_xxxx...
 *   Scope richiesto: admin:licenses  (oppure read:all per sola lettura)
 *   Genera la chiave in: NIS2 Agile → Settings → API Keys
 */

require_once __DIR__ . '/BaseController.php';

class InviteController extends BaseController
{
    // Prefisso token invito
    private const TOKEN_PREFIX = 'inv_';
    // Lunghezza parte random del token (bytes → hex)
    private const TOKEN_BYTES  = 24;

    // ══════════════════════════════════════════════════════════════════════
    // ADMIN — Generazione e gestione inviti
    // ══════════════════════════════════════════════════════════════════════

    /**
     * POST /api/invites
     * Genera uno o più inviti. Solo super_admin.
     *
     * Body:
     * {
     *   "plan": "professional",           // essentials/professional/enterprise
     *   "duration_months": 12,            // durata licenza attivata dall'invito
     *   "invite_expires_days": 30,        // giorni di validità dell'invito stesso (default 30)
     *   "max_uses": 1,                    // 1=one-shot, N=multi-use (es. batch lg231)
     *   "quantity": 1,                    // genera N inviti in batch (default 1)
     *   "label": "lg231 Partner Q1 2026", // etichetta descrittiva
     *   "channel": "lg231",               // 'lg231'|'ecommerce'|'direct'|'manual'
     *   "issued_to": "partner@lg231.it",  // destinatario (facoltativo)
     *   "restrict_vat": "02345678901",    // limita a P.IVA specifica (facoltativo)
     *   "restrict_email": "admin@co.it",  // limita a email admin specifica (facoltativo)
     *   "notes": "Ordine OPP-2026-042"   // note interne
     * }
     *
     * Response: { invites: [ {id, token, token_prefix, plan, expires_at, ...} ] }
     */
    public function create(): void
    {
        $this->requireLicenseAuth(writeRequired: true);

        $body           = $this->getBody();
        $plan           = in_array($body['plan'] ?? '', ['essentials','professional','enterprise'])
                          ? $body['plan'] : 'professional';
        $durationMonths = max(1, min(60, (int)($body['duration_months'] ?? 12)));
        $expiresDays    = max(1, min(365, (int)($body['invite_expires_days'] ?? 30)));
        $maxUses        = max(1, min(100, (int)($body['max_uses'] ?? 1)));
        $quantity       = max(1, min(50, (int)($body['quantity'] ?? 1)));
        $label          = substr(trim($body['label'] ?? ''), 0, 255) ?: null;
        $channel        = substr(trim($body['channel'] ?? 'manual'), 0, 64);
        $issuedTo       = substr(trim($body['issued_to'] ?? ''), 0, 255) ?: null;
        $restrictVat    = preg_replace('/[^0-9]/', '', $body['restrict_vat'] ?? '');
        $restrictEmail  = filter_var($body['restrict_email'] ?? '', FILTER_VALIDATE_EMAIL) ?: null;
        $notes          = trim($body['notes'] ?? '') ?: null;

        // Dati destinatario (pre-compilano il form di registrazione)
        $recipient = [];
        if (!empty($body['recipient_first_name'])) $recipient['first_name'] = substr(trim($body['recipient_first_name']), 0, 100);
        if (!empty($body['recipient_last_name']))  $recipient['last_name']  = substr(trim($body['recipient_last_name']),  0, 100);
        if (!empty($body['recipient_email']))      $recipient['email']      = filter_var(trim($body['recipient_email']), FILTER_VALIDATE_EMAIL) ?: null;
        if (!empty($body['recipient_company']))    $recipient['company']    = substr(trim($body['recipient_company']),    0, 255);
        if (!empty($body['recipient_vat']))        $recipient['vat']        = preg_replace('/[^0-9]/', '', $body['recipient_vat']);
        $metadata = !empty($recipient) ? json_encode(['recipient' => $recipient]) : null;
        $maxUsersPerOrg = isset($body['max_users_per_org']) ? max(1, min(9999, (int)$body['max_users_per_org'])) : null;
        $priceEur       = isset($body['price_eur']) ? round((float)$body['price_eur'], 2) : null;
        $resellerName   = substr(trim($body['reseller_name'] ?? ''), 0, 128) ?: null;
        $issuedBy       = $this->getCurrentUserId();
        $expiresAt      = date('Y-m-d H:i:s', strtotime("+{$expiresDays} days"));

        $created = [];
        for ($i = 0; $i < $quantity; $i++) {
            $rawToken = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
            $tokenHash = hash('sha256', $rawToken);
            $prefix    = substr($rawToken, 0, 10) . '...';

            Database::query(
                'INSERT INTO invites
                 (token_prefix, token_hash, plan, duration_months, label, notes,
                  max_uses, max_users_per_org, price_eur, reseller_name,
                  expires_at, channel, issued_to, issued_by,
                  restrict_vat, restrict_email, metadata)
                 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
                [
                    $prefix, $tokenHash, $plan, $durationMonths,
                    $label, $notes, $maxUses, $maxUsersPerOrg, $priceEur, $resellerName,
                    $expiresAt, $channel, $issuedTo, $issuedBy,
                    $restrictVat ?: null, $restrictEmail, $metadata,
                ]
            );
            $inviteId = (int) Database::lastInsertId();

            $created[] = [
                'id'                => $inviteId,
                'token'             => $rawToken,   // SOLO qui viene restituito in chiaro
                'token_prefix'      => $prefix,
                'plan'              => $plan,
                'duration_months'   => $durationMonths,
                'max_uses'          => $maxUses,
                'max_users_per_org' => $maxUsersPerOrg,
                'price_eur'         => $priceEur,
                'expires_at'        => $expiresAt,
                'channel'           => $channel,
                'issued_to'         => $issuedTo,
                'label'             => $label,
                'invite_url'        => APP_URL . '/register.html?invite=' . urlencode($rawToken),
                'recipient'         => !empty($recipient) ? $recipient : null,
                'provision_hint'    => 'POST /api/services/provision con invite_token: "' . $rawToken . '"',
            ];

            // Audit
            $this->logAudit('invite.created', 'invite', $inviteId, [
                'plan' => $plan, 'channel' => $channel,
                'expires_at' => $expiresAt, 'max_uses' => $maxUses,
                'max_users_per_org' => $maxUsersPerOrg,
            ]);
        }

        $this->jsonSuccess([
            'invites'  => $created,
            'count'    => count($created),
            'warning'  => 'Salva i token subito — non saranno più visibili in chiaro.',
        ], 201);
    }

    /**
     * GET /api/invites
     * Lista inviti con filtri. Solo super_admin.
     */
    public function index(): void
    {
        $this->requireLicenseAuth(writeRequired: false);

        $status  = $_GET['status']  ?? null;    // pending/used/expired/revoked
        $channel = $_GET['channel'] ?? null;
        $limit   = min(100, max(1, (int)($_GET['limit'] ?? 50)));
        $offset  = max(0, (int)($_GET['offset'] ?? 0));

        $where  = ['1=1'];
        $params = [];

        if ($status)  { $where[] = 'status = ?';  $params[] = $status; }
        if ($channel) { $where[] = 'channel = ?'; $params[] = $channel; }

        // Auto-scaduti: aggiorna status se expires_at passato
        Database::query(
            "UPDATE invites SET status='expired' WHERE status='pending' AND expires_at < NOW()"
        );

        $sql = 'SELECT id, token_prefix, plan, duration_months, label, max_uses, used_count,
                       expires_at, channel, issued_to, status, used_at, used_by_org_id,
                       restrict_vat, restrict_email, notes, created_at
                FROM invites
                WHERE ' . implode(' AND ', $where) .
               ' ORDER BY created_at DESC LIMIT ? OFFSET ?';

        $params[] = $limit;
        $params[] = $offset;

        $rows  = Database::fetchAll($sql, $params);
        $countParams = array_slice($params, 0, -2); // rimuovi limit e offset
        $total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM invites WHERE ' . implode(' AND ', $where), $countParams)['c'];

        // Aggiungi org_name per inviti usati
        foreach ($rows as &$row) {
            if ($row['used_by_org_id']) {
                $org = Database::fetchOne('SELECT name FROM organizations WHERE id=? LIMIT 1', [$row['used_by_org_id']]);
                $row['used_by_org_name'] = $org['name'] ?? null;
            }
        }

        $this->jsonSuccess(['invites' => $rows, 'total' => $total, 'limit' => $limit, 'offset' => $offset]);
    }

    /**
     * GET /api/invites/{id}
     * Dettaglio singolo invito. Solo super_admin.
     */
    public function show(int $id = 0): void
    {
        $this->requireLicenseAuth(writeRequired: false);

        $row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
        if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');

        unset($row['token_hash']); // non esporre mai l'hash

        if ($row['used_by_org_id']) {
            $org = Database::fetchOne('SELECT name, sector, nis2_entity_type FROM organizations WHERE id=?', [$row['used_by_org_id']]);
            $row['used_by_org'] = $org;
        }

        // Parsa metadata per esporre recipient strutturato
        if (!empty($row['metadata'])) {
            $meta = json_decode($row['metadata'], true) ?? [];
            $row['metadata_recipient'] = $meta['recipient'] ?? null;
        }

        $this->jsonSuccess($row);
    }

    /**
     * DELETE /api/invites/{id}
     * Revoca invito (non cancella — solo status=revoked). Solo super_admin.
     */
    public function revoke(int $id = 0): void
    {
        $this->requireLicenseAuth(writeRequired: true);
        $row = Database::fetchOne('SELECT id, status FROM invites WHERE id=? LIMIT 1', [$id]);
        if (!$row) $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
        if ($row['status'] === 'used') $this->jsonError('Invito già usato — non revocabile', 422, 'ALREADY_USED');

        Database::query("UPDATE invites SET status='revoked', updated_at=NOW() WHERE id=?", [$id]);
        $this->logAudit('invite.revoked', 'invite', $id);
        $this->jsonSuccess(['revoked' => true, 'id' => $id]);
    }

    /**
     * POST /api/invites/{id}/regenerate
     * Rigenera il token mantenendo config (piano, durata, scadenza). Solo super_admin.
     * Utile se il token è stato compromesso PRIMA dell'uso.
     */
    public function regenerate(int $id = 0): void
    {
        $this->requireLicenseAuth(writeRequired: true);
        $row = Database::fetchOne('SELECT * FROM invites WHERE id=? LIMIT 1', [$id]);
        if (!$row)                      $this->jsonError('Invito non trovato', 404, 'NOT_FOUND');
        if ($row['status'] === 'used')  $this->jsonError('Invito già usato — impossibile rigenerare', 422, 'ALREADY_USED');

        $rawToken  = self::TOKEN_PREFIX . bin2hex(random_bytes(self::TOKEN_BYTES));
        $tokenHash = hash('sha256', $rawToken);
        $prefix    = substr($rawToken, 0, 10) . '...';

        Database::query(
            "UPDATE invites SET token_prefix=?, token_hash=?, status='pending', updated_at=NOW() WHERE id=?",
            [$prefix, $tokenHash, $id]
        );

        $this->logAudit('invite.regenerated', 'invite', $id);
        $this->jsonSuccess([
            'id'         => $id,
            'token'      => $rawToken,
            'token_prefix' => $prefix,
            'warning'    => 'Nuovo token generato. Vecchio token invalidato.',
        ]);
    }

    // ══════════════════════════════════════════════════════════════════════
    // PUBBLICO — Validazione invito (anteprima)
    // ══════════════════════════════════════════════════════════════════════

    /**
     * GET /api/invites/validate/{token}
     * Valida un invito e restituisce il piano incluso.
     * Nessuna auth richiesta — usato da lg231 prima del provision
     * e dalla pagina onboarding.html per mostrare l'anteprima.
     */
    public function validate(): void
    {
        $token = $_GET['token'] ?? '';
        if (!$token) $this->jsonError('Token mancante', 400, 'MISSING_TOKEN');

        $result = self::resolveInvite($token);
        if (!$result['valid']) {
            $this->jsonError($result['error'], 422, $result['code']);
        }

        $inv = $result['invite'];
        $this->jsonSuccess([
            'valid'             => true,
            'plan'              => $inv['plan'],
            'duration_months'   => (int) $inv['duration_months'],
            'expires_at'        => $inv['expires_at'],
            'remaining_uses'    => (int)$inv['max_uses'] - (int)$inv['used_count'],
            'max_users_per_org' => $inv['max_users_per_org'] !== null ? (int)$inv['max_users_per_org'] : null,
            'channel'           => $inv['channel'],
            'label'             => $inv['label'],
            'plan_features'     => self::planFeatures($inv['plan']),
        ]);
    }

    // ══════════════════════════════════════════════════════════════════════
    // HELPERS STATICI (usati anche da ServicesController::provision)
    // ══════════════════════════════════════════════════════════════════════

    /**
     * Valida e restituisce l'invito dal token raw.
     * @return array { valid: bool, invite: array|null, error: string|null, code: string|null }
     */
    public static function resolveInvite(string $rawToken): array
    {
        if (!str_starts_with($rawToken, self::TOKEN_PREFIX)) {
            return ['valid' => false, 'invite' => null, 'error' => 'Formato token non valido', 'code' => 'INVALID_FORMAT'];
        }

        $hash   = hash('sha256', $rawToken);
        $invite = Database::fetchOne('SELECT * FROM invites WHERE token_hash=? LIMIT 1', [$hash]);

        if (!$invite) {
            return ['valid' => false, 'invite' => null, 'error' => 'Invito non trovato', 'code' => 'NOT_FOUND'];
        }

        if ($invite['status'] === 'revoked') {
            return ['valid' => false, 'invite' => $invite, 'error' => 'Invito revocato', 'code' => 'REVOKED'];
        }

        if ($invite['status'] === 'used' && (int)$invite['used_count'] >= (int)$invite['max_uses']) {
            return ['valid' => false, 'invite' => $invite, 'error' => 'Invito già utilizzato', 'code' => 'ALREADY_USED'];
        }

        if (strtotime($invite['expires_at']) < time()) {
            Database::query("UPDATE invites SET status='expired' WHERE id=?", [$invite['id']]);
            return ['valid' => false, 'invite' => $invite, 'error' => 'Invito scaduto il ' . $invite['expires_at'], 'code' => 'EXPIRED'];
        }

        return ['valid' => true, 'invite' => $invite, 'error' => null, 'code' => null];
    }

    /**
     * Segna l'invito come usato (chiamato dopo provisioning riuscito).
     */
    public static function markUsed(int $inviteId, int $orgId, string $ip): void
    {
        Database::query(
            "UPDATE invites
             SET used_count = used_count + 1,
                 status     = CASE WHEN used_count + 1 >= max_uses THEN 'used' ELSE 'pending' END,
                 used_at    = COALESCE(used_at, NOW()),
                 used_by_org_id = COALESCE(used_by_org_id, ?),
                 used_by_ip = ?,
                 updated_at = NOW()
             WHERE id = ?",
            [$orgId, $ip, $inviteId]
        );
    }

    /**
     * Feature list per piano (usata nella preview invito).
     */
    public static function planFeatures(string $plan): array
    {
        $features = [
            'essentials' => [
                'Gap Analysis Art.21 (80 domande)',
                'Registro Rischi (ISO 27005)',
                'Gestione Incidenti Art.23',
                'Audit Trail SHA-256',
                'Max 3 utenti',
                'API read-only',
            ],
            'professional' => [
                'Tutto Essentials',
                'Policy Management + AI',
                'Supply Chain Assessment',
                'Formazione & Compliance',
                'Whistleblowing anonimo',
                'Webhook outbound',
                'API read+write',
                'Max 10 utenti',
                'SSO federato',
            ],
            'enterprise' => [
                'Tutto Professional',
                'Organizzazioni illimitate',
                'Utenti illimitati',
                'API admin:org completa',
                'Provisioning automatico',
                'Benchmark settoriale',
                'SLA prioritario',
                'White-label disponibile',
            ],
        ];
        return $features[$plan] ?? $features['professional'];
    }

    // ══════════════════════════════════════════════════════════════════════
    // AUTH HELPER — JWT super_admin  OR  API Key con scope admin:licenses
    // ══════════════════════════════════════════════════════════════════════

    /**
     * Accetta:
     *   a) JWT super_admin (header Authorization: Bearer eyJ...)
     *   b) API Key con scope admin:licenses o read:all (header X-API-Key: nis2_...)
     *
     * Se nessuno dei due è valido → 401.
     * Se $writeRequired=true e la chiave ha solo read:all → 403.
     */
    private function requireLicenseAuth(bool $writeRequired = false): void
    {
        // ── tenta API Key ────────────────────────────────────────────────
        $rawKey = $_SERVER['HTTP_X_API_KEY']
               ?? (isset($_SERVER['HTTP_AUTHORIZATION']) && str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'ApiKey ')
                   ? substr($_SERVER['HTTP_AUTHORIZATION'], 7) : null);

        if ($rawKey) {
            if (!str_starts_with($rawKey, 'nis2_')) {
                $this->jsonError('Formato API Key non valido', 401, 'INVALID_API_KEY_FORMAT');
            }
            $hash = hash('sha256', $rawKey);
            $key  = Database::fetchOne(
                'SELECT id, organization_id, scopes, is_active, expires_at
                 FROM api_keys WHERE key_hash=? LIMIT 1',
                [$hash]
            );
            if (!$key || !$key['is_active']) {
                $this->jsonError('API Key non valida o disattivata', 401, 'INVALID_API_KEY');
            }
            if ($key['expires_at'] && strtotime($key['expires_at']) < time()) {
                $this->jsonError('API Key scaduta', 401, 'EXPIRED_API_KEY');
            }
            $scopes = json_decode($key['scopes'] ?? '[]', true);
            $hasAdmin = in_array('admin:licenses', $scopes, true);
            $hasRead  = in_array('read:all', $scopes, true) || in_array('admin:licenses', $scopes, true);

            if (!$hasRead) {
                $this->jsonError('Scope insufficiente (richiesto: admin:licenses o read:all)', 403, 'INSUFFICIENT_SCOPE');
            }
            if ($writeRequired && !$hasAdmin) {
                $this->jsonError('Scope insufficiente per scrittura (richiesto: admin:licenses)', 403, 'WRITE_SCOPE_REQUIRED');
            }

            // Aggiorna last_used_at
            Database::query('UPDATE api_keys SET last_used_at=NOW() WHERE id=?', [$key['id']]);
            return;
        }

        // ── fallback: JWT super_admin ────────────────────────────────────
        $this->requireAuth();
        $this->requireSuperAdmin();
    }

    // ── helper ───────────────────────────────────────────────────────────
    private function getBody(): array
    {
        return json_decode(file_get_contents('php://input'), true) ?? [];
    }
}
