<?php
/**
 * NIS2 Agile - Services Controller
 *
 * API pubblica per sistemi esterni (SIEM, GRC, 231 Agile, SustainAI, AllRisk).
 * Autenticazione via API Key (header X-API-Key o Bearer nis2_xxx).
 * Rate limiting: 100 req/h per chiave.
 *
 * Endpoint:
 *   GET /api/services/status
 *   GET /api/services/compliance-summary
 *   GET /api/services/risks/feed
 *   GET /api/services/incidents/feed
 *   GET /api/services/controls/status
 *   GET /api/services/assets/critical
 *   GET /api/services/suppliers/risk
 *   GET /api/services/policies/approved
 *   GET /api/services/openapi
 */

require_once __DIR__ . '/BaseController.php';

class ServicesController extends BaseController
{
    // ─── API Key autenticata ──────────────────────────────────────────────
    private ?array $apiKeyRecord = null;
    private const RATE_LIMIT_DIR = '/tmp/nis2_api_ratelimit/';
    private const RATE_LIMIT_MAX = 100;        // req per finestra
    private const RATE_LIMIT_WINDOW = 3600;    // secondi (1 ora)

    // ─── Versione API ─────────────────────────────────────────────────────
    private const API_VERSION = '1.0.0';

    // ══════════════════════════════════════════════════════════════════════
    // AUTH API KEY
    // ══════════════════════════════════════════════════════════════════════

    /**
     * Autentica la richiesta via API Key.
     * Cerca in:
     *   1. Header X-API-Key
     *   2. Authorization: Bearer nis2_xxx
     *   3. Query string ?api_key=nis2_xxx
     */
    private function requireApiKey(string $scope = 'read:all'): void
    {
        $rawKey = null;

        // 1. Header X-API-Key
        $rawKey = $_SERVER['HTTP_X_API_KEY'] ?? null;

        // 2. Bearer token
        if (!$rawKey) {
            $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
            if (str_starts_with($auth, 'Bearer nis2_')) {
                $rawKey = substr($auth, 7);
            }
        }

        // 3. Query string
        if (!$rawKey) {
            $rawKey = $_GET['api_key'] ?? null;
        }

        if (!$rawKey) {
            $this->jsonError('API Key mancante', 401, 'MISSING_API_KEY');
        }

        // Hash SHA-256 della chiave
        $keyHash = hash('sha256', $rawKey);

        // Cerca in DB
        $record = Database::fetchOne(
            'SELECT ak.*, o.name as org_name, o.entity_type as nis2_entity_type, o.sector
             FROM api_keys ak
             JOIN organizations o ON o.id = ak.organization_id
             WHERE ak.key_hash = ? AND ak.is_active = 1
               AND (ak.expires_at IS NULL OR ak.expires_at > NOW())',
            [$keyHash]
        );

        if (!$record) {
            $this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY');
        }

        // Verifica scope — read:all è master scope che include tutto
        $scopes = json_decode($record['scopes'], true) ?? [];
        $hasAll = in_array('read:all', $scopes);
        if (!$hasAll && !in_array($scope, $scopes)) {
            $this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED');
        }

        // Rate limiting per API key
        $this->checkRateLimit($record['key_prefix']);

        // Aggiorna last_used_at (async: non blocchiamo su errore)
        try {
            Database::query(
                'UPDATE api_keys SET last_used_at = NOW() WHERE id = ?',
                [$record['id']]
            );
        } catch (Throwable $e) {
            // non critico
        }

        $this->apiKeyRecord = $record;
        $this->currentOrgId = (int) $record['organization_id'];

        // ── Audit trail: ogni chiamata esterna autenticata viene registrata ──
        $this->logExternalCall($scope);
    }

    /**
     * Registra la chiamata esterna nell'audit trail con AuditService.
     * action: api.external_call — severity: info (warning se scope sensibile)
     */
    private function logExternalCall(string $scope): void
    {
        $rec      = $this->apiKeyRecord;
        $endpoint = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
        $caller   = $_SERVER['HTTP_X_CALLER'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? 'unknown');
        $method   = $_SERVER['REQUEST_METHOD'] ?? 'GET';

        AuditService::log(
            orgId:       $this->currentOrgId,
            userId:      null,
            action:      'api.external_call',
            entityType:  'api_key',
            entityId:    (int) $rec['id'],
            details:     [
                'endpoint'     => $endpoint,
                'method'       => $method,
                'scope'        => $scope,
                'caller'       => $caller,
                'key_name'     => $rec['name'] ?? 'n/a',
                'key_prefix'   => $rec['key_prefix'] ?? '',
                'org_name'     => $rec['org_name'] ?? '',
            ],
            ipAddress:   $_SERVER['REMOTE_ADDR'] ?? '',
            userAgent:   $caller,
            severity:    in_array($scope, ['read:incidents', 'read:all'], true) ? 'warning' : 'info',
            performedBy: $rec['name'] ?? 'api_key'
        );
    }

    /**
     * Rate limiting file-based per API Key
     */
    private function checkRateLimit(string $keyPrefix): void
    {
        if (!is_dir(self::RATE_LIMIT_DIR)) {
            @mkdir(self::RATE_LIMIT_DIR, 0755, true);
        }

        $file = self::RATE_LIMIT_DIR . 'key_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $keyPrefix) . '.json';
        $now  = time();
        $data = ['count' => 0, 'window_start' => $now];

        if (file_exists($file)) {
            $raw = @json_decode(file_get_contents($file), true);
            if ($raw && ($now - $raw['window_start']) < self::RATE_LIMIT_WINDOW) {
                $data = $raw;
            }
        }

        if ($data['count'] >= self::RATE_LIMIT_MAX) {
            $retryAfter = self::RATE_LIMIT_WINDOW - ($now - $data['window_start']);
            header('Retry-After: ' . $retryAfter);
            header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
            header('X-RateLimit-Remaining: 0');
            $this->jsonError('Rate limit superato. Max ' . self::RATE_LIMIT_MAX . ' req/h per API key.', 429, 'RATE_LIMITED');
        }

        $data['count']++;
        file_put_contents($file, json_encode($data), LOCK_EX);

        header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX);
        header('X-RateLimit-Remaining: ' . (self::RATE_LIMIT_MAX - $data['count']));
    }

    /**
     * Headers standard per tutte le risposte Services API
     */
    private function setServiceHeaders(): void
    {
        header('X-NIS2-API-Version: ' . self::API_VERSION);
        header('X-NIS2-Org-Id: ' . $this->currentOrgId);
    }

    // ══════════════════════════════════════════════════════════════════════
    // ENDPOINT
    // ══════════════════════════════════════════════════════════════════════

    /**
     * POST /api/services/token
     *
     * Token exchange: lg231 (e altri sistemi) inviano la loro API Key e
     * ricevono un JWT temporaneo (TTL 15 min) per le chiamate successive.
     * Pattern identico al CertiSource PAT → session.
     *
     * Request:  X-API-Key: nis2_xxx  (o body JSON {"api_key":"nis2_xxx"})
     * Response: {"token":"eyJ...", "expires_in":900, "org_id":5, "scopes":[...]}
     */
    public function token(): void
    {
        $this->requireApiKey('read:all');
        $this->setServiceHeaders();

        $rec    = $this->apiKeyRecord;
        $orgId  = $this->currentOrgId;
        $scopes = json_decode($rec['scopes'], true) ?? ['read:all'];
        $ttl    = 900; // 15 minuti

        // Carica JWT config dal config applicativo
        require_once APP_PATH . '/config/config.php';
        $secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');

        $issuedAt = time();
        $payload  = [
            'iss'      => 'nis2.agile.software',
            'sub'      => 'api_key:' . $rec['id'],
            'org_id'   => $orgId,
            'key_id'   => (int) $rec['id'],
            'scopes'   => $scopes,
            'caller'   => $_SERVER['HTTP_X_CALLER'] ?? 'external',
            'iat'      => $issuedAt,
            'exp'      => $issuedAt + $ttl,
            'type'     => 'service_token',
        ];

        // JWT manuale HS256 (stesso formato di BaseController)
        $header    = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $body      = $this->base64UrlEncode(json_encode($payload));
        $signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$body", $secret, true));
        $jwt       = "$header.$body.$signature";

        // Audit
        AuditService::log(
            orgId:       $orgId,
            userId:      null,
            action:      'api.token_issued',
            entityType:  'api_key',
            entityId:    (int) $rec['id'],
            details:     [
                'key_name' => $rec['name'] ?? 'n/a',
                'caller'   => $_SERVER['HTTP_X_CALLER'] ?? 'external',
                'ttl'      => $ttl,
                'scopes'   => $scopes,
            ],
            ipAddress:   $_SERVER['REMOTE_ADDR'] ?? '',
            severity:    'info',
            performedBy: $rec['name'] ?? 'api_key'
        );

        $this->jsonSuccess([
            'token'      => $jwt,
            'token_type' => 'Bearer',
            'expires_in' => $ttl,
            'expires_at' => date('c', $issuedAt + $ttl),
            'org_id'     => $orgId,
            'org_name'   => $rec['org_name'] ?? '',
            'scopes'     => $scopes,
        ]);
    }

    /**
     * POST /api/services/sso
     *
     * Single Sign-On federato: lg231 (o altro sistema Agile) invia
     * l'identità del suo utente e NIS2 emette un JWT di sessione valido
     * nell'app NIS2 — senza che l'utente debba fare login separato.
     *
     * Request (X-API-Key + JSON body):
     * {
     *   "user_email":    "tizio@azienda.it",
     *   "user_name":     "Mario Rossi",           // opzionale
     *   "user_role":     "compliance_manager",    // ruolo NIS2 desiderato
     *   "caller_system": "lg231",                 // sistema chiamante
     *   "caller_user_id": 42,                     // ID utente nel sistema chiamante
     *   "responsibilities": [                     // facoltativo
     *     {"area": "MOG 231", "scope": "art.24-bis"},
     *     {"area": "OdV",     "scope": "monitoraggio"}
     *   ]
     * }
     *
     * Response:
     * {
     *   "token": "eyJ...",      // JWT NIS2 valido 2h (usa nell'Authorization header)
     *   "user_id": 12,          // ID utente NIS2 (creato se non esiste)
     *   "org_id": 5,
     *   "role": "compliance_manager",
     *   "redirect_url": "https://nis2.agile.software/dashboard.html"
     * }
     *
     * Audit: logga l'accesso SSO con identità completa e responsabilità.
     */
    public function sso(): void
    {
        $this->requireApiKey('sso:login');
        $this->setServiceHeaders();

        $body   = json_decode(file_get_contents('php://input'), true) ?? [];
        $email  = trim($body['user_email'] ?? '');
        $name   = trim($body['user_name'] ?? '');
        $role   = $body['user_role'] ?? 'auditor';
        $caller = $body['caller_system'] ?? ($_SERVER['HTTP_X_CALLER'] ?? 'external');
        $callerUserId = $body['caller_user_id'] ?? null;
        $responsibilities = $body['responsibilities'] ?? [];

        if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $this->jsonError('user_email mancante o non valido', 400, 'INVALID_EMAIL');
        }

        // Ruoli NIS2 consentiti via SSO (non super_admin)
        $allowedRoles = ['compliance_manager', 'auditor', 'board_member', 'employee', 'consultant'];
        if (!in_array($role, $allowedRoles, true)) {
            $role = 'auditor';
        }

        $db    = Database::getInstance();
        $orgId = $this->currentOrgId;

        // Trova o crea l'utente NIS2 per questa email
        $user = Database::fetchOne('SELECT * FROM users WHERE email = ? LIMIT 1', [$email]);

        if (!$user) {
            // Crea utente SSO (senza password — accede solo via token)
            Database::query(
                'INSERT INTO users (email, password_hash, full_name, role, is_active)
                 VALUES (?, ?, ?, ?, 1)',
                [$email, '', $name ?: $email, $role]
            );
            $userId = (int) Database::lastInsertId();
        } else {
            $userId = (int) $user['id'];
        }

        // Assicura membership org
        $membership = Database::fetchOne(
            'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ? LIMIT 1',
            [$userId, $orgId]
        );
        if (!$membership) {
            Database::query(
                'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?, ?, ?)',
                [$userId, $orgId, $role]
            );
        }

        // Emetti JWT NIS2 (TTL 2h, stesso formato BaseController)
        require_once APP_PATH . '/config/config.php';
        $secret    = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');
        $issuedAt  = time();
        $ttl       = 7200; // 2 ore

        $payload = [
            'iss'             => 'nis2.agile.software',
            'sub'             => $userId,
            'org_id'          => $orgId,
            'role'            => $role,
            'sso'             => true,
            'sso_caller'      => $caller,
            'sso_caller_uid'  => $callerUserId,
            'responsibilities'=> $responsibilities,
            'iat'             => $issuedAt,
            'exp'             => $issuedAt + $ttl,
            'type'            => 'access',
        ];

        $header    = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $jwtBody   = $this->base64UrlEncode(json_encode($payload));
        $signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$jwtBody", $secret, true));
        $jwt       = "$header.$jwtBody.$signature";

        // Audit trail — traccia SSO con responsabilità
        AuditService::log(
            orgId:       $orgId,
            userId:      $userId,
            action:      'auth.sso_login',
            entityType:  'user',
            entityId:    $userId,
            details:     [
                'caller_system'    => $caller,
                'caller_user_id'   => $callerUserId,
                'role_granted'     => $role,
                'responsibilities' => $responsibilities,
                'email'            => $email,
                'user_created'     => !isset($user),
            ],
            ipAddress:   $_SERVER['REMOTE_ADDR'] ?? '',
            severity:    'warning',   // SSO login è sempre warning per audit
            performedBy: $caller . ':' . $email
        );

        $this->jsonSuccess([
            'token'        => $jwt,
            'token_type'   => 'Bearer',
            'expires_in'   => $ttl,
            'expires_at'   => date('c', $issuedAt + $ttl),
            'user_id'      => $userId,
            'user_email'   => $email,
            'org_id'       => $orgId,
            'role'         => $role,
            'redirect_url' => 'https://nis2.agile.software/dashboard.html',
            'sso_caller'   => $caller,
        ]);
    }

    /**
     * POST /api/services/provision
     *
     * Provisioning automatico B2B: lg231 (o altro sistema Agile partner)
     * acquista una licenza NIS2 per conto di un cliente e invia i dati.
     * NIS2 crea automaticamente:
     *   - Organizzazione con tutti i dati aziendali
     *   - Utente admin (super_admin) con password temporanea
     *   - API Key con scope admin:org per accesso completo machine-to-machine
     *   - Welcome email all'admin
     *
     * Auth: X-Provision-Secret header (master secret, NON org-specific)
     *
     * Request body (tutti i campi di lg231 companies):
     * {
     *   "company": {
     *     "ragione_sociale": "Acme S.r.l.",         // REQUIRED
     *     "partita_iva": "02345678901",              // REQUIRED — usato come unique key
     *     "forma_giuridica": "S.r.l.",
     *     "codice_fiscale": "ACMESRL02345678901",
     *     "ateco_code": "62.01.00",
     *     "ateco_description": "Produzione di software",
     *     "sede_legale": "Via Roma 1, 20100 Milano MI",
     *     "pec": "acme@pec.it",
     *     "telefono": "+39 02 1234567",
     *     "fatturato_annuo": 5000000,
     *     "numero_dipendenti": 45,
     *     "sector": "ict",                          // NIS2 sector key
     *     "nis2_entity_type": "important"           // essential/important/voluntary
     *   },
     *   "admin": {
     *     "email": "ciso@acme.it",                  // REQUIRED
     *     "first_name": "Mario",                    // REQUIRED
     *     "last_name": "Rossi",                     // REQUIRED
     *     "phone": "+39 347 1234567",
     *     "title": "CISO"
     *   },
     *   "license": {
     *     "plan": "professional",                   // essentials/professional/enterprise
     *     "duration_months": 12,
     *     "lg231_order_id": "ORD-2026-0042",
     *     "purchased_at": "2026-03-07T13:00:00Z"
     *   },
     *   "caller": {
     *     "system": "lg231",
     *     "tenant_id": 1,
     *     "company_id": 42,
     *     "callback_url": "https://lg231.agile.software/api/integrations/nis2-callback"
     *   }
     * }
     *
     * Response:
     * {
     *   "org_id": 8,
     *   "org_name": "Acme S.r.l.",
     *   "admin_user_id": 15,
     *   "admin_email": "ciso@acme.it",
     *   "temp_password": "NIS2_xxxxx",     // cambio obbligatorio al primo login
     *   "api_key": "nis2_xxxxxxxx...",     // salva in lg231 companies.metadata.nis2_api_key
     *   "access_token": "eyJ...",          // JWT 2h per uso immediato
     *   "dashboard_url": "https://nis2.agile.software/dashboard.html"
     * }
     */
    public function provision(): void
    {
        $this->setServiceHeaders();

        // ── 1. Leggi body (serve per invite_token) ───────────────────────
        $body    = json_decode(file_get_contents('php://input'), true) ?? [];
        $company = $body['company'] ?? [];
        $admin   = $body['admin']   ?? [];
        $license = $body['license'] ?? [];
        $caller  = $body['caller']  ?? [];

        // ── 2. Auth: invite_token (canale B2B) O X-Provision-Secret (admin diretto) ──
        $inviteToken = trim($body['invite_token'] ?? '');
        $resolvedInvite = null;

        if ($inviteToken) {
            // Percorso B2B tramite invito (lg231, e-commerce, partner)
            require_once __DIR__ . '/InviteController.php';
            $result = InviteController::resolveInvite($inviteToken);
            if (!$result['valid']) {
                $this->jsonError('Invito non valido: ' . $result['error'], 401, 'INVALID_INVITE_' . $result['code']);
            }
            $resolvedInvite = $result['invite'];

            // Verifica restrizione P.IVA se presente nell'invito
            $partitaIvaCheck = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
            if ($resolvedInvite['restrict_vat'] && $resolvedInvite['restrict_vat'] !== $partitaIvaCheck) {
                $this->jsonError('Invito non valido per questa P.IVA', 403, 'INVITE_VAT_MISMATCH');
            }
            // Verifica restrizione email admin se presente
            $adminEmailCheck = trim($admin['email'] ?? '');
            if ($resolvedInvite['restrict_email'] && strcasecmp($resolvedInvite['restrict_email'], $adminEmailCheck) !== 0) {
                $this->jsonError('Invito non valido per questa email admin', 403, 'INVITE_EMAIL_MISMATCH');
            }

            // Piano e durata vengono dall'invito (non dal body)
            $license['plan']           = $resolvedInvite['plan'];
            $license['duration_months'] = (int)$resolvedInvite['duration_months'];
        } else {
            // Percorso diretto admin/e-commerce via secret header
            $secret = $_SERVER['HTTP_X_PROVISION_SECRET']
                   ?? (isset($_SERVER['HTTP_AUTHORIZATION'])
                       ? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION'])
                       : null);
            if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) {
                $this->jsonError('invite_token o X-Provision-Secret richiesti', 401, 'AUTH_REQUIRED');
            }
        }

        $ragioneSociale = trim($company['ragione_sociale'] ?? '');
        $partitaIva     = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? '');
        $adminEmail     = trim($admin['email'] ?? '');
        // Supporta sia full_name che first_name+last_name
        $adminFullName  = trim($admin['full_name']
            ?? trim(($admin['first_name'] ?? '') . ' ' . ($admin['last_name'] ?? '')));

        if (!$ragioneSociale)                        $this->jsonError('company.ragione_sociale obbligatorio', 400, 'MISSING_FIELD');
        if (strlen($partitaIva) !== 11)              $this->jsonError('company.partita_iva non valida (11 cifre)', 400, 'INVALID_VAT');
        if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $this->jsonError('admin.email non valida', 400, 'INVALID_EMAIL');
        if (!$adminFullName)                         $this->jsonError('admin.full_name obbligatorio', 400, 'MISSING_FIELD');

        $db = Database::getInstance();

        // ── 3. Idempotency: se org con stessa P.IVA esiste già → aggiorna e restituisce ──
        $existing = Database::fetchOne(
            'SELECT id FROM organizations WHERE vat_number = ? LIMIT 1',
            [$partitaIva]
        );

        // Mappa sector lg231 → NIS2 (valori enum: energy,transport,banking,health,water,
        //   digital_infra,public_admin,manufacturing,postal,chemical,food,waste,
        //   ict_services,digital_providers,space,research,other)
        $sectorMap = [
            'energia'    => 'energy',    'energia_elettrica' => 'energy',
            'trasporti'  => 'transport', 'sanità' => 'health', 'sanita' => 'health',
            'finanza'    => 'banking',   'finance' => 'banking',
            'acqua'      => 'water',
            'ict'        => 'ict_services', 'digital' => 'digital_providers',
            'manifattura'=> 'manufacturing',
            'gestione_rifiuti' => 'waste',
            'consulting' => 'other', 'servizi_professionali' => 'other', 'social' => 'other',
        ];
        $rawSector      = strtolower($company['sector'] ?? 'ict');
        $nis2Sector     = $sectorMap[$rawSector] ?? $rawSector;
        $rawEntityType  = $company['nis2_entity_type'] ?? 'important';
        $nis2EntityType = match($rawEntityType) {
            'essential' => 'essential',
            'voluntary' => 'not_applicable',
            default     => 'important',
        };

        if ($existing) {
            $orgId = (int) $existing['id'];
        } else {
            // ── 4. Crea organizzazione ───────────────────────────────────
            Database::query(
                'INSERT INTO organizations
                 (name, vat_number, fiscal_code,
                  annual_turnover_eur, employee_count,
                  sector, entity_type, is_active,
                  provisioned_by, provisioned_at, license_plan, license_expires_at,
                  license_max_users, lg231_company_id, lg231_order_id)
                 VALUES (?,?,?,?,?,?,?,1,?,NOW(),?,?,?,?,?)',
                [
                    $ragioneSociale,
                    $partitaIva,
                    $company['codice_fiscale'] ?? null,
                    $company['fatturato_annuo'] ?? null,
                    $company['numero_dipendenti'] ?? null,
                    $nis2Sector,
                    $nis2EntityType,
                    $caller['system'] ?? 'external',
                    $license['plan'] ?? 'professional',
                    isset($license['duration_months'])
                        ? date('Y-m-d', strtotime('+' . (int)$license['duration_months'] . ' months'))
                        : date('Y-m-d', strtotime('+12 months')),
                    $resolvedInvite ? ($resolvedInvite['max_users_per_org'] !== null ? (int)$resolvedInvite['max_users_per_org'] : null) : null,
                    $caller['company_id'] ?? null,
                    $license['lg231_order_id'] ?? null,
                ]
            );
            $orgId = (int) Database::lastInsertId();
        }

        // ── 5. Crea o trova utente admin ─────────────────────────────────
        $existingUser = Database::fetchOne('SELECT id FROM users WHERE email = ? LIMIT 1', [$adminEmail]);

        $tempPassword = 'NIS2_' . bin2hex(random_bytes(6));
        $passwordHash = password_hash($tempPassword, PASSWORD_BCRYPT, ['cost' => 12]);

        if ($existingUser) {
            $userId = (int) $existingUser['id'];
        } else {
            Database::query(
                'INSERT INTO users (email, password_hash, full_name, role, is_active,
                  phone, job_title, must_change_password)
                 VALUES (?,?,?,\'org_admin\',1,?,?,1)',
                [$adminEmail, $passwordHash, $adminFullName,
                 $admin['phone'] ?? null, $admin['title'] ?? null]
            );
            $userId = (int) Database::lastInsertId();
        }

        // Assicura membership
        $mem = Database::fetchOne(
            'SELECT id FROM user_organizations WHERE user_id=? AND organization_id=? LIMIT 1',
            [$userId, $orgId]
        );
        if (!$mem) {
            Database::query(
                'INSERT INTO user_organizations (user_id, organization_id, role, is_primary) VALUES (?,?,\'org_admin\',1)',
                [$userId, $orgId]
            );
        }

        // ── 6. Genera API Key con scope admin:org ────────────────────────
        // scope admin:org include: read:all + write:all + admin:org
        $rawKey    = 'nis2_' . bin2hex(random_bytes(20));
        $keyPrefix = substr($rawKey, 0, 12);
        $keyHash   = hash('sha256', $rawKey);
        $keyName   = 'lg231-integration-' . $partitaIva;
        $expiresAt = $license['duration_months']
            ? date('Y-m-d H:i:s', strtotime('+' . (int)$license['duration_months'] . ' months'))
            : date('Y-m-d H:i:s', strtotime('+12 months'));

        // Revoca eventuali chiavi lg231-integration precedenti (idempotency)
        Database::query(
            'UPDATE api_keys SET is_active=0 WHERE organization_id=? AND name LIKE \'lg231-integration-%\'',
            [$orgId]
        );

        Database::query(
            'INSERT INTO api_keys (organization_id, name, key_prefix, key_hash, scopes, is_active, expires_at, created_by)
             VALUES (?,?,?,?,?,1,?,?)',
            [
                $orgId, $keyName, $keyPrefix, $keyHash,
                json_encode(['read:all', 'write:all', 'admin:org', 'sso:login']),
                $expiresAt,
                $userId,  // created_by: admin utente provisioned
            ]
        );

        // ── 7. JWT accesso immediato (2h) ────────────────────────────────
        $issuedAt = time();
        $jwtPayload = [
            'user_id'     => $userId,   // campo standard atteso da requireAuth()
            'iat'         => $issuedAt,
            'exp'         => $issuedAt + JWT_EXPIRES_IN,
        ];
        $h   = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $p   = $this->base64UrlEncode(json_encode($jwtPayload));
        $sig = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", JWT_SECRET, true));
        $jwt = "$h.$p.$sig";

        // ── 8. Audit trail ───────────────────────────────────────────────
        AuditService::log(
            orgId:       $orgId,
            userId:      $userId,
            action:      'org.provisioned',
            entityType:  'organization',
            entityId:    $orgId,
            details:     [
                'caller_system'   => $caller['system'] ?? 'external',
                'caller_company'  => $caller['company_id'] ?? null,
                'lg231_order_id'  => $license['lg231_order_id'] ?? null,
                'license_plan'    => $license['plan'] ?? 'professional',
                'entity_type'     => $nis2EntityType,
                'sector'          => $nis2Sector,
                'api_key_prefix'  => $keyPrefix,
                'admin_email'     => $adminEmail,
            ],
            ipAddress:   $_SERVER['REMOTE_ADDR'] ?? '',
            severity:    'critical',
            performedBy: ($caller['system'] ?? 'external') . ':provision'
        );

        // ── 9. Segna invito come usato (se presente) ────────────────────
        if ($resolvedInvite) {
            InviteController::markUsed(
                (int)$resolvedInvite['id'],
                $orgId,
                $_SERVER['REMOTE_ADDR'] ?? ''
            );
        }

        // ── 10. Callback a lg231 (asincrono, non bloccante) ──────────────
        $callbackUrl = $caller['callback_url'] ?? null;
        if ($callbackUrl) {
            $callbackPayload = json_encode([
                'event'        => 'nis2.provisioned',
                'org_id'       => $orgId,
                'company_id'   => $caller['company_id'] ?? null,
                'api_key'      => $rawKey,
                'provisioned_at' => date('c'),
            ]);
            $cbSig = 'sha256=' . hash_hmac('sha256', $callbackPayload, PROVISION_SECRET);

            // Fire-and-forget (ignora errori)
            @file_get_contents($callbackUrl, false, stream_context_create([
                'http' => [
                    'method'  => 'POST',
                    'header'  => "Content-Type: application/json\r\nX-NIS2-Signature: $cbSig\r\n",
                    'content' => $callbackPayload,
                    'timeout' => 3,
                    'ignore_errors' => true,
                ]
            ]));
        }

        $this->jsonSuccess([
            'provisioned'   => true,
            'org_id'        => $orgId,
            'org_name'      => $ragioneSociale,
            'vat_number'    => $partitaIva,
            'entity_type'   => $nis2EntityType,
            'sector'        => $nis2Sector,
            'admin_user_id' => $userId,
            'admin_email'   => $adminEmail,
            // Credenziali machine-to-machine
            'api_key'       => $rawKey,
            'api_key_scopes'=> ['read:all', 'write:all', 'admin:org', 'sso:login'],
            'api_key_expires_at' => $expiresAt,
            // JWT per apertura immediata UI
            'access_token'  => $jwt,
            'token_type'    => 'Bearer',
            'token_expires_in' => 7200,
            // Credenziali primo accesso admin (cambio obbligatorio)
            'temp_password' => !$existingUser ? $tempPassword : null,
            'must_change_password' => !isset($existingUser),
            // Link
            'dashboard_url' => APP_URL . '/dashboard.html',
            'settings_url'  => APP_URL . '/settings.html',
            // Info invito (se usato)
            'invite_id'     => $resolvedInvite ? (int)$resolvedInvite['id'] : null,
            'invite_plan'   => $resolvedInvite ? $resolvedInvite['plan'] : ($license['plan'] ?? 'professional'),
            'license_expires_at' => $expiresAt,
        ]);
    }

    /**
     * GET /api/services/status
     * Health check + info piattaforma. Nessuna auth richiesta.
     */
    public function status(): void
    {
        $this->setServiceHeaders();

        $this->jsonSuccess([
            'platform'    => 'NIS2 Agile',
            'version'     => self::API_VERSION,
            'status'      => 'operational',
            'regulation'  => ['EU 2022/2555', 'D.Lgs. 138/2024', 'ISO 27001/27005'],
            'ai_provider' => 'Anthropic Claude',
            'timestamp'   => date('c'),
            'endpoints'   => [
                'compliance_summary' => '/api/services/compliance-summary',
                'risks_feed'         => '/api/services/risks/feed',
                'incidents_feed'     => '/api/services/incidents/feed',
                'controls_status'    => '/api/services/controls/status',
                'critical_assets'    => '/api/services/assets/critical',
                'suppliers_risk'     => '/api/services/suppliers/risk',
                'approved_policies'  => '/api/services/policies/approved',
                'openapi'            => '/api/services/openapi',
                'docs'               => '/docs/api',
            ],
        ], 'NIS2 Agile Services API - Operational');
    }

    /**
     * GET /api/services/compliance-summary
     * Compliance score aggregato per dominio Art.21.
     * Scope: read:compliance
     */
    public function complianceSummary(): void
    {
        $this->requireApiKey('read:compliance');
        $this->setServiceHeaders();

        $orgId = $this->currentOrgId;

        // Score da assessment più recente completato
        $assessment = Database::fetchOne(
            'SELECT * FROM assessments WHERE organization_id = ? AND status = "completed"
             ORDER BY completed_at DESC LIMIT 1',
            [$orgId]
        );

        $overallScore = null;
        $domainScores = [];
        $recommendations = [];

        if ($assessment) {
            // Calcola score per dominio — response_value: not_implemented=0, partial=2, implemented=4, not_applicable=null
            $responses = Database::fetchAll(
                'SELECT question_code, category, response_value
                 FROM assessment_responses
                 WHERE assessment_id = ?',
                [$assessment['id']]
            );

            $byCategory = [];
            foreach ($responses as $r) {
                $cat = $r['category'] ?? 'uncategorized';
                if (!isset($byCategory[$cat])) {
                    $byCategory[$cat] = ['total' => 0, 'count' => 0];
                }
                $val = match($r['response_value'] ?? 'not_implemented') {
                    'implemented'     => 4,
                    'partial'         => 2,
                    'not_implemented' => 0,
                    default           => 0,
                };
                if ($r['response_value'] !== 'not_applicable') {
                    $byCategory[$cat]['total'] += $val;
                    $byCategory[$cat]['count']++;
                }
            }

            $totalScore = 0;
            $catCount = 0;
            foreach ($byCategory as $cat => $data) {
                $score = $data['count'] > 0
                    ? round(($data['total'] / ($data['count'] * 4)) * 100)
                    : 0;
                $domainScores[] = [
                    'domain'    => $cat,
                    'score'     => $score,
                    'status'    => $score >= 70 ? 'compliant' : ($score >= 40 ? 'partial' : 'gap'),
                ];
                $totalScore += $score;
                $catCount++;
            }

            $overallScore = $catCount > 0 ? round($totalScore / $catCount) : 0;

            // Raccomandazioni AI se disponibili
            if (!empty($assessment['ai_analysis'])) {
                $aiData = json_decode($assessment['ai_analysis'], true);
                $recommendations = $aiData['recommendations'] ?? [];
            }
        }

        // Risk summary (risk_level calcolato da inherent_risk_score: >=16 critical, >=9 high)
        $riskStats = Database::fetchOne(
            'SELECT
               COUNT(*) as total,
               SUM(CASE WHEN status NOT IN ("closed") THEN 1 ELSE 0 END) as open_count,
               SUM(CASE WHEN inherent_risk_score >= 9 AND status NOT IN ("closed") THEN 1 ELSE 0 END) as high_critical,
               SUM(CASE WHEN status = "monitored" THEN 1 ELSE 0 END) as mitigated
             FROM risks WHERE organization_id = ?',
            [$orgId]
        );

        // Incident summary
        $incidentStats = Database::fetchOne(
            'SELECT
               COUNT(*) as total,
               SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count,
               SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant,
               SUM(CASE WHEN early_warning_sent_at IS NOT NULL THEN 1 ELSE 0 END) as notified_acn
             FROM incidents WHERE organization_id = ?',
            [$orgId]
        );

        // Policy summary
        $policyStats = Database::fetchOne(
            'SELECT
               COUNT(*) as total,
               SUM(CASE WHEN status = "approved" THEN 1 ELSE 0 END) as approved,
               SUM(CASE WHEN status IN ("draft","review") THEN 1 ELSE 0 END) as pending
             FROM policies WHERE organization_id = ?',
            [$orgId]
        );

        $org = Database::fetchOne(
            'SELECT name, entity_type, sector, employee_count FROM organizations WHERE id = ?',
            [$orgId]
        );

        $this->jsonSuccess([
            'organization' => [
                'name'        => $org['name'],
                'entity_type' => $org['entity_type'],
                'sector'      => $org['sector'],
            ],
            'overall_score'     => $overallScore,
            'score_label'       => $this->scoreLabel($overallScore),
            'domain_scores'     => $domainScores,
            'assessment'        => $assessment ? [
                'id'           => $assessment['id'],
                'completed_at' => $assessment['completed_at'],
                'status'       => $assessment['status'],
            ] : null,
            'risks' => [
                'total'        => (int)($riskStats['total'] ?? 0),
                'open'         => (int)($riskStats['open_count'] ?? 0),
                'high_critical'=> (int)($riskStats['high_critical'] ?? 0),
                'mitigated'    => (int)($riskStats['mitigated'] ?? 0),
            ],
            'incidents' => [
                'total'        => (int)($incidentStats['total'] ?? 0),
                'open'         => (int)($incidentStats['open_count'] ?? 0),
                'significant'  => (int)($incidentStats['significant'] ?? 0),
                'notified_acn' => (int)($incidentStats['notified_acn'] ?? 0),
            ],
            'policies' => [
                'total'    => (int)($policyStats['total'] ?? 0),
                'approved' => (int)($policyStats['approved'] ?? 0),
                'pending'  => (int)($policyStats['pending'] ?? 0),
            ],
            'top_recommendations' => array_slice($recommendations, 0, 5),
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/risks/feed
     * Feed rischi filtrabili.
     * Scope: read:risks
     * Query: ?level=high,critical &from=2026-01-01 &area=it &limit=50
     */
    public function risksFeed(): void
    {
        $this->requireApiKey('read:risks');
        $this->setServiceHeaders();

        $orgId  = $this->currentOrgId;
        $where  = 'r.organization_id = ? AND r.deleted_at IS NULL';
        $params = [$orgId];

        if (!empty($_GET['level'])) {
            // risk_level è calcolato da inherent_risk_score
            $levelMap = ['critical' => 16, 'high' => 9, 'medium' => 4, 'low' => 0];
            $levels = array_filter(explode(',', $_GET['level']));
            $conditions = [];
            foreach ($levels as $lv) {
                $lv = strtolower(trim($lv));
                if ($lv === 'critical') { $conditions[] = 'r.inherent_risk_score >= 16'; }
                elseif ($lv === 'high')     { $conditions[] = '(r.inherent_risk_score >= 9 AND r.inherent_risk_score < 16)'; }
                elseif ($lv === 'medium')   { $conditions[] = '(r.inherent_risk_score >= 4 AND r.inherent_risk_score < 9)'; }
                elseif ($lv === 'low')      { $conditions[] = 'r.inherent_risk_score < 4'; }
            }
            if ($conditions) $where .= ' AND ('.implode(' OR ', $conditions).')';
        }

        if (!empty($_GET['area'])) {
            $where  .= ' AND r.category = ?';
            $params[] = $_GET['area'];
        }

        if (!empty($_GET['status'])) {
            $where  .= ' AND r.status = ?';
            $params[] = $_GET['status'];
        }

        if (!empty($_GET['from'])) {
            $where  .= ' AND r.created_at >= ?';
            $params[] = $_GET['from'] . ' 00:00:00';
        }

        $limit  = min(200, max(1, (int)($_GET['limit'] ?? 50)));

        $risks = Database::fetchAll(
            "SELECT r.id, r.title, r.description, r.category, r.likelihood,
                    r.impact, r.inherent_risk_score,
                    CASE WHEN r.inherent_risk_score >= 16 THEN 'critical'
                         WHEN r.inherent_risk_score >= 9  THEN 'high'
                         WHEN r.inherent_risk_score >= 4  THEN 'medium'
                         ELSE 'low' END as risk_level,
                    r.status, r.treatment, r.residual_risk_score,
                    r.created_at, r.updated_at
             FROM risks r
             WHERE {$where}
             ORDER BY r.inherent_risk_score DESC, r.created_at DESC
             LIMIT {$limit}",
            $params
        );

        $total = Database::count('risks', 'organization_id = ? AND deleted_at IS NULL', [$orgId]);

        $this->jsonSuccess([
            'risks'   => $risks,
            'total'   => $total,
            'fetched' => count($risks),
            'filters' => [
                'level'  => $_GET['level'] ?? null,
                'area'   => $_GET['area'] ?? null,
                'status' => $_GET['status'] ?? null,
                'from'   => $_GET['from'] ?? null,
            ],
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/incidents/feed
     * Feed incidenti Art.23 filtrabili.
     * Scope: read:incidents
     * Query: ?status=open &severity=high,critical &from=2026-01-01 &significant=1
     */
    public function incidentsFeed(): void
    {
        $this->requireApiKey('read:incidents');
        $this->setServiceHeaders();

        $orgId  = $this->currentOrgId;
        $where  = 'organization_id = ?';
        $params = [$orgId];

        if (!empty($_GET['status'])) {
            $where  .= ' AND status = ?';
            $params[] = $_GET['status'];
        }

        if (!empty($_GET['severity'])) {
            $severities = array_filter(explode(',', $_GET['severity']));
            $ph = implode(',', array_fill(0, count($severities), '?'));
            $where  .= " AND severity IN ({$ph})";
            $params  = array_merge($params, $severities);
        }

        if (!empty($_GET['significant'])) {
            $where  .= ' AND is_significant = 1';
        }

        if (!empty($_GET['from'])) {
            $where  .= ' AND detected_at >= ?';
            $params[] = $_GET['from'] . ' 00:00:00';
        }

        $limit = min(200, max(1, (int)($_GET['limit'] ?? 50)));

        $incidents = Database::fetchAll(
            "SELECT id, title, classification, severity, status, is_significant,
                    detected_at, closed_at,
                    early_warning_sent_at, notification_sent_at, final_report_sent_at,
                    early_warning_due, notification_due, final_report_due,
                    affected_services, root_cause,
                    created_at, updated_at
             FROM incidents
             WHERE {$where}
             ORDER BY detected_at DESC
             LIMIT {$limit}",
            $params
        );

        // Aggiungi stato scadenze Art.23
        $now = time();
        foreach ($incidents as &$inc) {
            $detectedTs = strtotime($inc['detected_at']);
            $inc['art23_status'] = [
                'early_warning_24h' => [
                    'required'  => (bool)$inc['is_significant'],
                    'deadline'  => date('c', $detectedTs + 86400),
                    'sent'      => $inc['early_warning_sent_at'] !== null,
                    'overdue'   => !$inc['early_warning_sent_at'] && $now > $detectedTs + 86400,
                ],
                'notification_72h' => [
                    'required'  => (bool)$inc['is_significant'],
                    'deadline'  => date('c', $detectedTs + 259200),
                    'sent'      => $inc['notification_sent_at'] !== null,
                    'overdue'   => !$inc['notification_sent_at'] && $now > $detectedTs + 259200,
                ],
                'final_report_30d' => [
                    'required'  => (bool)$inc['is_significant'],
                    'deadline'  => date('c', $detectedTs + 2592000),
                    'sent'      => $inc['final_report_sent_at'] !== null,
                    'overdue'   => !$inc['final_report_sent_at'] && $now > $detectedTs + 2592000,
                ],
            ];
        }
        unset($inc);

        $total = Database::count('incidents', 'organization_id = ?', [$orgId]);

        $this->jsonSuccess([
            'incidents'    => $incidents,
            'total'        => $total,
            'fetched'      => count($incidents),
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/controls/status
     * Stato controlli di sicurezza Art.21 per dominio.
     * Scope: read:compliance
     */
    public function controlsStatus(): void
    {
        $this->requireApiKey('read:compliance');
        $this->setServiceHeaders();

        $orgId = $this->currentOrgId;

        $controls = Database::fetchAll(
            'SELECT id, control_code, title, framework, status,
                    implementation_percentage, evidence_description, next_review_date, updated_at
             FROM compliance_controls
             WHERE organization_id = ?
             ORDER BY framework, control_code',
            [$orgId]
        );

        // Raggruppa per framework
        $byCategory = [];
        foreach ($controls as $ctrl) {
            $cat = $ctrl['framework'] ?? 'nis2';
            if (!isset($byCategory[$cat])) {
                $byCategory[$cat] = [
                    'category' => $cat,
                    'controls' => [],
                    'stats'    => ['total' => 0, 'implemented' => 0, 'in_progress' => 0, 'not_started' => 0, 'verified' => 0],
                ];
            }
            $byCategory[$cat]['controls'][] = $ctrl;
            $byCategory[$cat]['stats']['total']++;
            $s = $ctrl['status'] ?? 'not_started';
            if (isset($byCategory[$cat]['stats'][$s])) {
                $byCategory[$cat]['stats'][$s]++;
            }
        }

        // Score per categoria
        foreach ($byCategory as &$cat) {
            $t = $cat['stats']['total'];
            $i = $cat['stats']['implemented'] + ($cat['stats']['verified'] ?? 0);
            $p = $cat['stats']['in_progress'] ?? 0;
            $cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0;
        }
        unset($cat);

        $totals = [
            'total'       => count($controls),
            'implemented' => 0,
            'verified'    => 0,
            'in_progress' => 0,
            'not_started' => 0,
        ];
        foreach ($controls as $ctrl) {
            $s = $ctrl['status'] ?? 'not_started';
            if (isset($totals[$s])) $totals[$s]++;
        }
        $done = $totals['implemented'] + $totals['verified'];
        $totals['overall_score'] = $totals['total'] > 0
            ? round((($done + $totals['in_progress'] * 0.5) / $totals['total']) * 100)
            : 0;

        $this->jsonSuccess([
            'summary'      => $totals,
            'by_category'  => array_values($byCategory),
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/assets/critical
     * Asset critici e dipendenze.
     * Scope: read:assets
     * Query: ?type=server,network &criticality=high,critical
     */
    public function assetsCritical(): void
    {
        $this->requireApiKey('read:assets');
        $this->setServiceHeaders();

        $orgId  = $this->currentOrgId;
        $where  = 'organization_id = ?';
        $params = [$orgId];

        if (!empty($_GET['type'])) {
            $types = array_filter(explode(',', $_GET['type']));
            $ph = implode(',', array_fill(0, count($types), '?'));
            $where  .= " AND asset_type IN ({$ph})";
            $params  = array_merge($params, $types);
        }

        if (!empty($_GET['criticality'])) {
            $crits = array_filter(explode(',', $_GET['criticality']));
            $ph = implode(',', array_fill(0, count($crits), '?'));
            $where  .= " AND criticality IN ({$ph})";
            $params  = array_merge($params, $crits);
        } else {
            // Default: solo high e critical
            $where  .= " AND criticality IN ('high','critical')";
        }

        $assets = Database::fetchAll(
            "SELECT id, name, asset_type, criticality, status,
                    owner_user_id, location, ip_address, description,
                    dependencies, created_at
             FROM assets
             WHERE {$where}
             ORDER BY FIELD(criticality,'critical','high','medium','low'), name",
            $params
        );

        $this->jsonSuccess([
            'assets'       => $assets,
            'total'        => count($assets),
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/suppliers/risk
     * Supplier risk overview (supply chain security).
     * Scope: read:supply_chain
     * Query: ?risk_level=high,critical &status=active
     */
    public function suppliersRisk(): void
    {
        $this->requireApiKey('read:supply_chain');
        $this->setServiceHeaders();

        $orgId  = $this->currentOrgId;
        $where  = 's.organization_id = ? AND s.deleted_at IS NULL';
        $params = [$orgId];

        if (!empty($_GET['risk_level'])) {
            // risk_score: 0-100, mappa high=>=60, critical=>=80
            $levels = array_filter(explode(',', $_GET['risk_level']));
            $conditions = [];
            foreach ($levels as $lv) {
                $lv = strtolower(trim($lv));
                if ($lv === 'critical')   { $conditions[] = 's.risk_score >= 80'; }
                elseif ($lv === 'high')   { $conditions[] = '(s.risk_score >= 60 AND s.risk_score < 80)'; }
                elseif ($lv === 'medium') { $conditions[] = '(s.risk_score >= 30 AND s.risk_score < 60)'; }
                elseif ($lv === 'low')    { $conditions[] = 's.risk_score < 30'; }
            }
            if ($conditions) $where .= ' AND ('.implode(' OR ', $conditions).')';
        }

        if (!empty($_GET['status'])) {
            $where  .= ' AND s.status = ?';
            $params[] = $_GET['status'];
        }

        $suppliers = Database::fetchAll(
            "SELECT s.id, s.name, s.service_type, s.criticality, s.risk_score, s.status,
                    s.last_assessment_date, s.contact_email,
                    s.created_at, s.updated_at
             FROM suppliers s
             WHERE {$where}
             ORDER BY FIELD(s.criticality,'critical','high','medium','low'), s.risk_score DESC, s.name",
            $params
        );

        $stats = Database::fetchOne(
            "SELECT
               COUNT(*) as total,
               SUM(CASE WHEN risk_score >= 60 AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk,
               SUM(CASE WHEN criticality IN ('critical','high') AND deleted_at IS NULL THEN 1 ELSE 0 END) as critical_deps,
               SUM(CASE WHEN last_assessment_date IS NULL AND deleted_at IS NULL THEN 1 ELSE 0 END) as unassessed
             FROM suppliers WHERE organization_id = ?",
            [$orgId]
        );

        $this->jsonSuccess([
            'summary'      => $stats,
            'suppliers'    => $suppliers,
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/policies/approved
     * Policy approvate con metadati (no contenuto full per default).
     * Scope: read:policies
     * Query: ?category=... &include_content=1
     */
    public function policiesApproved(): void
    {
        $this->requireApiKey('read:policies');
        $this->setServiceHeaders();

        $orgId          = $this->currentOrgId;
        $includeContent = !empty($_GET['include_content']);

        $select = $includeContent
            ? 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated, content'
            : 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated';

        $where  = 'organization_id = ? AND status = "approved"';
        $params = [$orgId];

        if (!empty($_GET['category'])) {
            $where  .= ' AND category = ?';
            $params[] = $_GET['category'];
        }

        $policies = Database::fetchAll(
            "SELECT {$select} FROM policies WHERE {$where} ORDER BY category, title",
            $params
        );

        $this->jsonSuccess([
            'policies'     => $policies,
            'total'        => count($policies),
            'generated_at' => date('c'),
        ]);
    }

    /**
     * GET /api/services/openapi
     * Specifica OpenAPI 3.0 JSON per questa API.
     */
    public function openapi(): void
    {
        $this->setServiceHeaders();
        header('Content-Type: application/json; charset=utf-8');

        $spec = [
            'openapi' => '3.0.3',
            'info'    => [
                'title'       => 'NIS2 Agile Services API',
                'description' => 'API pubblica per integrazione con sistemi esterni. Espone dati di compliance NIS2, rischi, incidenti, controlli, asset e supply chain.',
                'version'     => self::API_VERSION,
                'contact'     => ['email' => 'presidenza@agile.software'],
                'license'     => ['name' => 'Proprietary', 'url' => 'https://agile.software'],
            ],
            'servers' => [
                ['url' => 'https://nis2.agile.software', 'description' => 'Production'],
            ],
            'security' => [
                ['ApiKeyHeader' => []],
                ['BearerToken'  => []],
            ],
            'components' => [
                'securitySchemes' => [
                    'ApiKeyHeader' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'],
                    'BearerToken'  => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'nis2_xxxxx'],
                ],
            ],
            'paths' => [
                '/api/services/status' => [
                    'get' => [
                        'summary'     => 'Status piattaforma',
                        'description' => 'Health check. Nessuna autenticazione richiesta.',
                        'security'    => [],
                        'responses'   => ['200' => ['description' => 'Platform operational']],
                        'tags'        => ['System'],
                    ],
                ],
                '/api/services/compliance-summary' => [
                    'get' => [
                        'summary'     => 'Compliance summary',
                        'description' => 'Score aggregato per dominio Art.21, risk/incident/policy stats.',
                        'responses'   => ['200' => ['description' => 'Compliance summary'], '401' => ['description' => 'API Key mancante']],
                        'tags'        => ['Compliance'],
                    ],
                ],
                '/api/services/risks/feed' => [
                    'get' => [
                        'summary'     => 'Risk feed',
                        'description' => 'Feed rischi filtrabili per level, area, status, data.',
                        'parameters'  => [
                            ['name' => 'level', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
                            ['name' => 'area', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
                            ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50, 'maximum' => 200]],
                        ],
                        'responses'   => ['200' => ['description' => 'List of risks']],
                        'tags'        => ['Risks'],
                    ],
                ],
                '/api/services/incidents/feed' => [
                    'get' => [
                        'summary'     => 'Incident feed Art.23',
                        'description' => 'Feed incidenti con stato scadenze Art.23 (24h/72h/30d).',
                        'parameters'  => [
                            ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'],
                            ['name' => 'significant', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
                            ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']],
                        ],
                        'responses'   => ['200' => ['description' => 'List of incidents']],
                        'tags'        => ['Incidents'],
                    ],
                ],
                '/api/services/controls/status' => [
                    'get' => [
                        'summary'     => 'Controlli Art.21 status',
                        'description' => 'Stato implementazione controlli per dominio di sicurezza.',
                        'responses'   => ['200' => ['description' => 'Controls by domain']],
                        'tags'        => ['Compliance'],
                    ],
                ],
                '/api/services/assets/critical' => [
                    'get' => [
                        'summary'     => 'Asset critici',
                        'description' => 'Inventario asset con criticality high/critical.',
                        'parameters'  => [
                            ['name' => 'type', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'criticality', 'in' => 'query', 'schema' => ['type' => 'string']],
                        ],
                        'responses'   => ['200' => ['description' => 'Critical assets']],
                        'tags'        => ['Assets'],
                    ],
                ],
                '/api/services/suppliers/risk' => [
                    'get' => [
                        'summary'     => 'Supplier risk overview',
                        'description' => 'Supply chain risk: fornitori per livello rischio.',
                        'parameters'  => [
                            ['name' => 'risk_level', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']],
                        ],
                        'responses'   => ['200' => ['description' => 'Suppliers risk data']],
                        'tags'        => ['Supply Chain'],
                    ],
                ],
                '/api/services/policies/approved' => [
                    'get' => [
                        'summary'     => 'Policy approvate',
                        'description' => 'Lista policy con status approved.',
                        'parameters'  => [
                            ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
                            ['name' => 'include_content', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]],
                        ],
                        'responses'   => ['200' => ['description' => 'Approved policies']],
                        'tags'        => ['Policies'],
                    ],
                ],
                // ── Inbound: ingestion & evidence automation (scope dedicati) ──
                '/api/services/incidents-ingest' => [
                    'post' => [
                        'summary'     => 'Ingestion incidente da SIEM/SOC/EDR (apertura automatica Art.23)',
                        'description' => 'Crea un incidente da alert esterno. Dedup su external_id, classificazione AI, severity normalizzata. Scope: ingest:incidents.',
                        'responses'   => ['201' => ['description' => 'Incidente creato'], '200' => ['description' => 'Dedup'], '403' => ['description' => 'Scope mancante']],
                        'tags'        => ['Ingestion'],
                    ],
                ],
                '/api/services/evidence-ingest' => [
                    'post' => [
                        'summary'     => 'Ingestion evidenze automatiche (Continuous Control Monitoring)',
                        'description' => 'Registra evidenze per un controllo da connettori M365/Google/AWS/Azure/IdP/EDR/SIEM. Batch via evidences[], upsert su external_ref. Scope: ingest:evidence.',
                        'responses'   => ['201' => ['description' => 'Evidenze elaborate'], '403' => ['description' => 'Scope mancante']],
                        'tags'        => ['Ingestion'],
                    ],
                ],
                '/api/services/controls-monitoring' => [
                    'get' => [
                        'summary'     => 'Stato monitoraggio continuo dei controlli',
                        'description' => 'Copertura e semafori freschezza (healthy/warning/stale/failing). Scope: read:compliance.',
                        'responses'   => ['200' => ['description' => 'OK']],
                        'tags'        => ['Compliance'],
                    ],
                ],
                '/api/services/assets-ingest' => [
                    'post' => [
                        'summary'     => 'Import asset da CMDB/cloud con scoring automatico GV.OC-04',
                        'description' => 'Upsert asset (dedup su external_ref) con calcolo automatico rilevanza NIS2. Scope: ingest:assets.',
                        'responses'   => ['201' => ['description' => 'Asset importati'], '403' => ['description' => 'Scope mancante']],
                        'tags'        => ['Ingestion'],
                    ],
                ],
            ],
            'tags' => [
                ['name' => 'System'],
                ['name' => 'Compliance'],
                ['name' => 'Risks'],
                ['name' => 'Incidents'],
                ['name' => 'Assets'],
                ['name' => 'Supply Chain'],
                ['name' => 'Policies'],
                ['name' => 'Ingestion'],
            ],
        ];

        echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        exit;
    }

    // ── Utility ───────────────────────────────────────────────────────────

    /**
     * GET /api/services/full-snapshot
     * Aggregato di tutti i flussi lg231 in una sola chiamata.
     * Riduce da 6 round-trip a 1. Risposta: { gap_analysis, measures, incidents, training, deadlines, compliance_summary }
     * ?days=30  finestra deadlines (default 30)
     */
    public function fullSnapshot(): void
    {
        $this->requireApiKey('read:all');
        $orgId = $this->currentOrgId;
        $days  = min((int) ($this->getParam('days') ?: 30), 365);

        // Riusa la logica degli endpoint esistenti catturando i dati direttamente
        // senza fare HTTP round-trip interni

        // ── compliance summary (già esistente) ────────────────────────────
        $org = Database::fetchOne('SELECT name, sector, entity_type, employee_count FROM organizations WHERE id = ?', [$orgId]);
        $ctrlStats = Database::fetchOne(
            'SELECT COUNT(*) as total,
                    SUM(CASE WHEN status IN ("implemented","verified") THEN 1 ELSE 0 END) as done,
                    SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as wip
             FROM compliance_controls WHERE organization_id = ?', [$orgId]
        );
        $complianceScore = ($ctrlStats['total'] ?? 0) > 0
            ? (int) round(($ctrlStats['done'] + $ctrlStats['wip'] * 0.5) / $ctrlStats['total'] * 100)
            : 0;

        // ── gap analysis ─────────────────────────────────────────────────
        $assessment = Database::fetchOne(
            'SELECT id, overall_score, ai_summary, ai_recommendations, completed_at
             FROM assessments WHERE organization_id = ? AND status = "completed"
             ORDER BY completed_at DESC LIMIT 1', [$orgId]
        );
        $gapDomains = [];
        if ($assessment) {
            $responses = Database::fetchAll(
                'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?',
                [$assessment['id']]
            );
            $dData = []; $dGaps = [];
            foreach ($responses as $r) {
                $cat = $r['category'];
                if (!isset($dData[$cat])) { $dData[$cat] = ['implemented'=>0,'partial'=>0,'not_implemented'=>0,'not_applicable'=>0]; $dGaps[$cat] = []; }
                match ($r['response_value']) {
                    'implemented' => $dData[$cat]['implemented']++, 'partial' => $dData[$cat]['partial']++,
                    'not_implemented' => $dData[$cat]['not_implemented']++, 'not_applicable' => $dData[$cat]['not_applicable']++,
                    default => null,
                };
                if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) $dGaps[$cat][] = $r['question_text'];
            }
            $mogMap = ['governance'=>'pillar_1_governance','risk_management'=>'pillar_2_risk_assessment','incident_management'=>'pillar_7_segnalazioni','business_continuity'=>'pillar_5_monitoraggio','supply_chain'=>'pillar_3_procedure_operative','vulnerability'=>'pillar_5_monitoraggio','policy_measurement'=>'pillar_3_procedure_operative','training_awareness'=>'pillar_4_formazione','cryptography'=>'pillar_6_sicurezza_it','access_control'=>'pillar_6_sicurezza_it'];
            $artMap = ['governance'=>'Art.20-21','risk_management'=>'Art.21.2.a','incident_management'=>'Art.21.2.b','business_continuity'=>'Art.21.2.c','supply_chain'=>'Art.21.2.d','vulnerability'=>'Art.21.2.e','policy_measurement'=>'Art.21.2.f','training_awareness'=>'Art.21.2.g','cryptography'=>'Art.21.2.h','access_control'=>'Art.21.2.i'];
            $gapActions = ['critical'=>'Intervento immediato: piano d\'azione entro 30 giorni.','high'=>'Priorità alta: avviare implementazione nel prossimo sprint.','medium'=>'Completare misure parziali entro il prossimo trimestre.','low'=>'Revisione periodica annuale.'];
            foreach ($dData as $domain => $counts) {
                $scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
                $score = $scorable > 0 ? (int) round(($counts['implemented']*4+$counts['partial']*2)/($scorable*4)*100) : null;
                $gl = match(true) { $score===null=>'not_assessed', $score>=75=>'low', $score>=50=>'medium', $score>=25=>'high', default=>'critical' };
                $gapDomains[] = ['domain'=>$domain,'nis2_article'=>$artMap[$domain]??'Art.21','mog_pillar'=>$mogMap[$domain]??'pillar_3_procedure_operative','score'=>$score,'gap_level'=>$gl,'implemented'=>$counts['implemented'],'partial'=>$counts['partial'],'not_implemented'=>$counts['not_implemented'],'not_implemented_items'=>array_slice($dGaps[$domain]??[],0,3),'suggested_action'=>$gapActions[$gl]??null];
            }
            usort($gapDomains, fn($a,$b) => ($a['score']??101)<=>($b['score']??101));
        }

        // ── incidents ────────────────────────────────────────────────────
        $now = time();
        $incRows = Database::fetchAll(
            'SELECT id, title, classification, severity, status, is_significant, detected_at,
                    early_warning_due, early_warning_sent_at, notification_due, notification_sent_at,
                    final_report_due, final_report_sent_at
             FROM incidents WHERE organization_id = ? ORDER BY detected_at DESC LIMIT 20', [$orgId]
        );
        $incidents = [];
        $csirtOverdue = 0;
        foreach ($incRows as $r) {
            $isOpen = !in_array($r['status'],['closed','post_mortem']); $isSig = (bool)$r['is_significant'];
            $notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null;
            $notOv  = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
            if ($notOv) $csirtOverdue++;
            $incidents[] = ['id'=>(int)$r['id'],'title'=>$r['title'],'severity'=>$r['severity'],'status'=>$r['status'],'is_significant'=>$isSig,'detected_at'=>$r['detected_at'],'art23'=>['early_warning_due'=>$r['early_warning_due'],'early_warning_sent'=>!empty($r['early_warning_sent_at']),'early_warning_overdue'=>$isSig&&$isOpen&&$r['early_warning_due']&&$now>strtotime($r['early_warning_due'])&&!$r['early_warning_sent_at'],'notification_due'=>$r['notification_due'],'notification_sent'=>!empty($r['notification_sent_at']),'notification_overdue'=>$notOv,'final_report_due'=>$r['final_report_due'],'final_report_sent'=>!empty($r['final_report_sent_at']),'final_report_overdue'=>$isSig&&$isOpen&&$r['final_report_due']&&$now>strtotime($r['final_report_due'])&&!$r['final_report_sent_at']]];
        }

        // ── training ─────────────────────────────────────────────────────
        $courses = Database::fetchAll('SELECT id,title,target_role,nis2_article,is_mandatory,duration_minutes FROM training_courses WHERE (organization_id=? OR organization_id IS NULL) AND is_active=1 ORDER BY is_mandatory DESC,title', [$orgId]);
        $trainingData = ['courses_total'=>0,'mandatory_total'=>0,'overall_completion_rate'=>0,'board_completion_rate'=>0,'art20_compliance'=>false,'non_compliant_mandatory'=>[]];
        if (!empty($courses)) {
            $cids = array_column($courses,'id');
            $ph   = implode(',', array_fill(0,count($cids),'?'));
            $agg  = Database::fetchAll("SELECT course_id, COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments WHERE organization_id=? AND course_id IN ({$ph}) GROUP BY course_id", array_merge([$orgId],$cids));
            $am   = []; foreach ($agg as $a) $am[(int)$a['course_id']] = ['total'=>(int)$a['total'],'completed'=>(int)$a['completed']];
            $board = Database::fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN ta.status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments ta JOIN user_organizations uo ON uo.user_id=ta.user_id AND uo.organization_id=ta.organization_id WHERE ta.organization_id=? AND uo.role='board_member'", [$orgId]);
            $tTot=0; $tComp=0; $mBTot=0; $mBComp=0; $cList=[];
            foreach ($courses as $c) {
                $cid=(int)$c['id']; $a=$am[$cid]??['total'=>0,'completed'=>0];
                $rate=$a['total']>0?(int)round($a['completed']/$a['total']*100):0;
                $tTot+=$a['total']; $tComp+=$a['completed'];
                if ($c['is_mandatory']&&$c['target_role']==='board_member') { $mBTot+=$a['total']; $mBComp+=$a['completed']; }
                $cList[]=['id'=>$cid,'title'=>$c['title'],'target_role'=>$c['target_role'],'nis2_article'=>$c['nis2_article'],'is_mandatory'=>(bool)$c['is_mandatory'],'assigned'=>$a['total'],'completed'=>$a['completed'],'completion_rate'=>$rate];
            }
            $nonCompliant = array_values(array_filter($cList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));
            $trainingData = ['courses_total'=>count($courses),'mandatory_total'=>count(array_filter($courses,fn($c)=>$c['is_mandatory'])),'assignments_total'=>$tTot,'assignments_completed'=>$tComp,'overall_completion_rate'=>$tTot>0?(int)round($tComp/$tTot*100):0,'board_completion_rate'=>(int)($board['total']??0)>0?(int)round((int)($board['completed']??0)/(int)$board['total']*100):0,'art20_compliance'=>$mBTot>0&&$mBComp>=$mBTot,'non_compliant_mandatory'=>$nonCompliant,'courses'=>$cList];
        }

        // ── deadlines ────────────────────────────────────────────────────
        $horizon = $now + ($days * 86400);
        $deadlines = [];
        foreach (Database::fetchAll('SELECT id,title,severity,early_warning_due,early_warning_sent_at,notification_due,notification_sent_at,final_report_due,final_report_sent_at FROM incidents WHERE organization_id=? AND is_significant=1 AND status NOT IN ("closed","post_mortem")', [$orgId]) as $inc) {
            foreach ([['notification_72h',$inc['notification_due'],$inc['notification_sent_at']],['early_warning_24h',$inc['early_warning_due'],$inc['early_warning_sent_at']],['final_report_30d',$inc['final_report_due'],$inc['final_report_sent_at']]] as [$sub,$due,$sent]) {
                if (!$due||$sent) continue; $ts=strtotime($due); if ($ts>$horizon) continue;
                $deadlines[]=['type'=>'incident_notification','subtype'=>$sub,'title'=>$sub.': '.$inc['title'],'due_date'=>$due,'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'critical','reference_id'=>(int)$inc['id'],'nis2_article'=>'Art.23'];
            }
        }
        foreach (Database::fetchAll('SELECT id,control_code,title,next_review_date,status FROM compliance_controls WHERE organization_id=? AND next_review_date IS NOT NULL AND status!="verified"', [$orgId]) as $c) {
            $ts=strtotime($c['next_review_date']); if ($ts>$horizon) continue;
            $deadlines[]=['type'=>'control_review','subtype'=>$c['status'],'title'=>'Revisione: '.$c['title'],'due_date'=>$c['next_review_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'medium','reference_id'=>(int)$c['id'],'nis2_article'=>'Art.21'];
        }
        foreach (Database::fetchAll('SELECT id,name,criticality,next_assessment_date FROM suppliers WHERE organization_id=? AND deleted_at IS NULL AND next_assessment_date IS NOT NULL', [$orgId]) as $s) {
            $ts=strtotime($s['next_assessment_date']); if ($ts>$horizon) continue;
            $deadlines[]=['type'=>'supplier_assessment','subtype'=>$s['criticality'],'title'=>'Valutazione fornitore: '.$s['name'],'due_date'=>$s['next_assessment_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>in_array($s['criticality'],['critical','high'])?'high':'medium','reference_id'=>(int)$s['id'],'nis2_article'=>'Art.21.2.d'];
        }
        usort($deadlines, fn($a,$b) => $b['overdue']<=>$a['overdue'] ?: strtotime($a['due_date'])<=>strtotime($b['due_date']));

        $this->jsonSuccess([
            'org'                => ['id' => $orgId, 'name' => $org['name'] ?? '', 'sector' => $org['sector'] ?? '', 'entity_type' => $org['entity_type'] ?? ''],
            'compliance_score'   => $complianceScore,
            'gap_analysis'       => ['assessment_id' => $assessment ? (int)$assessment['id'] : null, 'overall_score' => $assessment ? $assessment['overall_score'] : null, 'completed_at' => $assessment['completed_at'] ?? null, 'domains' => $gapDomains],
            'incidents'          => ['total' => count($incRows), 'csirt_overdue' => $csirtOverdue, 'items' => $incidents],
            'training'           => $trainingData,
            'deadlines'          => ['days_horizon' => $days, 'overdue' => count(array_filter($deadlines,fn($d)=>$d['overdue'])), 'total' => count($deadlines), 'items' => $deadlines],
            'generated_at'       => date('c'),
        ]);
    }

    private function scoreLabel(?int $score): string
    {
        if ($score === null) return 'not_assessed';
        if ($score >= 80) return 'compliant';
        if ($score >= 60) return 'substantially_compliant';
        if ($score >= 40) return 'partial';
        return 'significant_gaps';
    }

    // ══════════════════════════════════════════════════════════════════════
    // lg231 / GRC INTEGRATION ENDPOINTS
    // ══════════════════════════════════════════════════════════════════════

    /**
     * GET /api/services/gap-analysis
     * Gap analysis per dominio NIS2 Art.21 con mapping ai 7 pilastri MOG 231.
     * Basato sull'assessment più recente completato.
     */
    public function gapAnalysis(): void
    {
        $this->requireApiKey('read:compliance');
        $orgId = $this->currentOrgId;

        // Mapping NIS2 domain → MOG 231 pillar e articolo
        $domainMeta = [
            'governance'          => ['article' => 'Art.20-21',  'mog_pillar' => 'pillar_1_governance'],
            'risk_management'     => ['article' => 'Art.21.2.a', 'mog_pillar' => 'pillar_2_risk_assessment'],
            'incident_management' => ['article' => 'Art.21.2.b', 'mog_pillar' => 'pillar_7_segnalazioni'],
            'business_continuity' => ['article' => 'Art.21.2.c', 'mog_pillar' => 'pillar_5_monitoraggio'],
            'supply_chain'        => ['article' => 'Art.21.2.d', 'mog_pillar' => 'pillar_3_procedure_operative'],
            'vulnerability'       => ['article' => 'Art.21.2.e', 'mog_pillar' => 'pillar_5_monitoraggio'],
            'policy_measurement'  => ['article' => 'Art.21.2.f', 'mog_pillar' => 'pillar_3_procedure_operative'],
            'training_awareness'  => ['article' => 'Art.21.2.g', 'mog_pillar' => 'pillar_4_formazione'],
            'cryptography'        => ['article' => 'Art.21.2.h', 'mog_pillar' => 'pillar_6_sicurezza_it'],
            'access_control'      => ['article' => 'Art.21.2.i', 'mog_pillar' => 'pillar_6_sicurezza_it'],
        ];

        // Assessment più recente completato
        $assessment = Database::fetchOne(
            'SELECT id, overall_score, category_scores, ai_summary, ai_recommendations, completed_at
             FROM assessments
             WHERE organization_id = ? AND status = "completed"
             ORDER BY completed_at DESC LIMIT 1',
            [$orgId]
        );

        if (!$assessment) {
            $this->jsonSuccess([
                'assessment_id'   => null,
                'completed_at'    => null,
                'overall_score'   => null,
                'domains'         => [],
                'ai_summary'      => null,
                'ai_recommendations' => [],
                'note'            => 'Nessun assessment completato. Avvia un gap analysis dal modulo Assessment.',
            ]);
            return;
        }

        // Risposte per assessment (incluse domande non implementate per suggested_action)
        $responses = Database::fetchAll(
            'SELECT category, response_value, question_text
             FROM assessment_responses
             WHERE assessment_id = ?',
            [$assessment['id']]
        );

        // Calcola score per dominio dalle risposte + raccoglie items non implementati
        $domainData  = [];
        $domainGaps  = [];   // category → [question_text, ...]
        foreach ($responses as $r) {
            $cat = $r['category'];
            if (!isset($domainData[$cat])) {
                $domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0];
                $domainGaps[$cat] = [];
            }
            match ($r['response_value']) {
                'implemented'     => $domainData[$cat]['implemented']++,
                'partial'         => $domainData[$cat]['partial']++,
                'not_implemented' => $domainData[$cat]['not_implemented']++,
                'not_applicable'  => $domainData[$cat]['not_applicable']++,
                default           => null,
            };
            if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) {
                $domainGaps[$cat][] = $r['question_text'];
            }
        }

        $domains = [];
        foreach ($domainData as $domain => $counts) {
            $scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented'];
            $score    = $scorable > 0
                ? (int) round(($counts['implemented'] * 4 + $counts['partial'] * 2) / ($scorable * 4) * 100)
                : null;
            $gapLevel = match (true) {
                $score === null  => 'not_assessed',
                $score >= 75     => 'low',
                $score >= 50     => 'medium',
                $score >= 25     => 'high',
                default          => 'critical',
            };
            $meta = $domainMeta[$domain] ?? ['article' => 'Art.21', 'mog_pillar' => 'pillar_3_procedure_operative'];
            $domains[] = [
                'domain'          => $domain,
                'nis2_article'    => $meta['article'],
                'mog_pillar'      => $meta['mog_pillar'],
                'score'           => $score,
                'gap_level'       => $gapLevel,
                'implemented'     => $counts['implemented'],
                'partial'         => $counts['partial'],
                'not_implemented' => $counts['not_implemented'],
                'not_applicable'  => $counts['not_applicable'],
            ];
        }

        // Ordina per score ASC (gap peggiori prima)
        usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101));

        // AI recommendations globali
        $aiRecs = [];
        if (!empty($assessment['ai_recommendations'])) {
            $decoded = json_decode($assessment['ai_recommendations'], true);
            if (is_array($decoded)) $aiRecs = $decoded;
        }

        // Default suggested_action per gap_level se non ci sono AI recs
        $gapActions = [
            'critical' => 'Intervento immediato richiesto: definire piano d\'azione con responsabile e scadenza entro 30 giorni.',
            'high'     => 'Priorità alta: avviare implementazione misure mancanti nel prossimo sprint di compliance.',
            'medium'   => 'Completare le misure parzialmente implementate entro il prossimo trimestre.',
            'low'      => 'Mantenere il livello attuale con revisione periodica annuale.',
        ];

        // Aggiungi suggested_action e not_implemented_items a ogni dominio
        foreach ($domains as &$d) {
            $d['not_implemented_items'] = array_slice($domainGaps[$d['domain']] ?? [], 0, 5);
            $d['suggested_action'] = $gapActions[$d['gap_level']] ?? null;
        }
        unset($d);

        $this->jsonSuccess([
            'assessment_id'      => (int) $assessment['id'],
            'completed_at'       => $assessment['completed_at'],
            'overall_score'      => $assessment['overall_score'] !== null ? (int) $assessment['overall_score'] : null,
            'domains'            => $domains,
            'ai_summary'         => $assessment['ai_summary'],
            'ai_recommendations' => $aiRecs,
        ]);
    }

    /**
     * GET /api/services/measures
     * Misure di sicurezza Art.21 con stato implementazione e mapping MOG 231.
     * ?framework=nis2|iso27001|both  (default: tutti)
     * ?status=not_started|in_progress|implemented|verified
     */
    public function measures(): void
    {
        $this->requireApiKey('read:compliance');
        $orgId = $this->currentOrgId;

        // Mapping control_code prefix → mog_area
        $mogAreaMap = [
            'NIS2-20'     => 'governance',
            'NIS2-21'     => 'governance',
            'NIS2-21.2.a' => 'risk_assessment',
            'NIS2-21.2.b' => 'incident_management',
            'NIS2-21.2.c' => 'business_continuity',
            'NIS2-21.2.d' => 'supply_chain',
            'NIS2-21.2.e' => 'vulnerability_management',
            'NIS2-21.2.f' => 'policy_procedure',
            'NIS2-21.2.g' => 'training_awareness',
            'NIS2-21.2.h' => 'cryptography',
            'NIS2-21.2.i' => 'access_control',
            'NIS2-21.2.j' => 'access_control',
            'ISO'         => 'iso27001_control',
        ];

        $where  = 'organization_id = ?';
        $params = [$orgId];

        $filterFramework = $this->getParam('framework');
        if ($filterFramework && in_array($filterFramework, ['nis2', 'iso27001', 'both'])) {
            $where   .= ' AND framework = ?';
            $params[] = $filterFramework;
        }

        $filterStatus = $this->getParam('status');
        if ($filterStatus) {
            $where   .= ' AND status = ?';
            $params[] = $filterStatus;
        }

        $controls = Database::fetchAll(
            "SELECT id, control_code, framework, title, status,
                    implementation_percentage, next_review_date
             FROM compliance_controls
             WHERE {$where}
             ORDER BY framework, control_code",
            $params
        );

        $stats = ['total' => 0, 'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
        $measures = [];
        foreach ($controls as $c) {
            $stats['total']++;
            $s = $c['status'] ?? 'not_started';
            if (isset($stats[$s])) $stats[$s]++;

            $code = $c['control_code'] ?? '';
            // Derive mog_area from control_code
            $mogArea = 'other';
            foreach ($mogAreaMap as $prefix => $area) {
                if (str_starts_with($code, $prefix)) { $mogArea = $area; break; }
            }
            if ($mogArea === 'other' && str_starts_with($code, 'ISO')) $mogArea = 'iso27001_control';

            // Derive nis2_article from code (e.g. "NIS2-21.2.a" → "Art.21.2.a")
            $nis2Article = null;
            if (preg_match('/^NIS2-(\S+)/', $code, $m)) {
                $nis2Article = 'Art.' . $m[1];
            }

            $measures[] = [
                'id'                        => (int) $c['id'],
                'code'                      => $code,
                'framework'                 => $c['framework'],
                'title'                     => $c['title'],
                'status'                    => $s,
                'implementation_percentage' => (int) ($c['implementation_percentage'] ?? 0),
                'nis2_article'              => $nis2Article,
                'mog_area'                  => $mogArea,
                'next_review_date'          => $c['next_review_date'],
            ];
        }

        $completionPct = $stats['total'] > 0
            ? (int) round(($stats['implemented'] + $stats['verified'] + $stats['in_progress'] * 0.5) / $stats['total'] * 100)
            : 0;

        $this->jsonSuccess(array_merge($stats, [
            'completion_percentage' => $completionPct,
            'measures'              => $measures,
        ]));
    }

    /**
     * GET /api/services/incidents
     * Incidenti con stato Art.23 CSIRT (diverso da incidentsFeed: focus compliance).
     * ?significant=1   solo incidenti significativi
     * ?status=open     solo aperti (non closed/post_mortem)
     * ?limit=50
     */
    public function incidents(): void
    {
        $this->requireApiKey('read:incidents');
        $orgId = $this->currentOrgId;

        $limit  = min((int) ($this->getParam('limit') ?: 50), 200);
        $where  = 'organization_id = ?';
        $params = [$orgId];

        if ($this->getParam('significant') === '1') {
            $where .= ' AND is_significant = 1';
        }
        if ($this->getParam('status') === 'open') {
            $where .= ' AND status NOT IN ("closed","post_mortem")';
        }

        $rows = Database::fetchAll(
            "SELECT id, title, classification, severity, status, is_significant,
                    detected_at, closed_at,
                    early_warning_due, early_warning_sent_at,
                    notification_due, notification_sent_at,
                    final_report_due, final_report_sent_at,
                    affected_services, root_cause
             FROM incidents
             WHERE {$where}
             ORDER BY detected_at DESC
             LIMIT {$limit}",
            $params
        );

        $now      = time();
        $csirtOverdue = 0;
        $incidents = [];

        foreach ($rows as $r) {
            $isOpen = !in_array($r['status'], ['closed', 'post_mortem']);
            $isSig  = (bool) $r['is_significant'];

            // Art.23 compliance per incidente significativo aperto
            $ewDue   = $r['early_warning_due'] ? strtotime($r['early_warning_due']) : null;
            $notDue  = $r['notification_due']  ? strtotime($r['notification_due'])  : null;
            $frDue   = $r['final_report_due']  ? strtotime($r['final_report_due'])  : null;

            $notOverdue = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at'];
            if ($notOverdue) $csirtOverdue++;

            $incidents[] = [
                'id'             => (int) $r['id'],
                'title'          => $r['title'],
                'classification' => $r['classification'],
                'severity'       => $r['severity'],
                'status'         => $r['status'],
                'is_significant' => $isSig,
                'detected_at'    => $r['detected_at'],
                'closed_at'      => $r['closed_at'],
                'art23'          => [
                    'early_warning_due'    => $r['early_warning_due'],
                    'early_warning_sent'   => !empty($r['early_warning_sent_at']),
                    'early_warning_overdue'=> $isSig && $isOpen && $ewDue && $now > $ewDue && !$r['early_warning_sent_at'],
                    'notification_due'     => $r['notification_due'],
                    'notification_sent'    => !empty($r['notification_sent_at']),
                    'notification_overdue' => $notOverdue,
                    'final_report_due'     => $r['final_report_due'],
                    'final_report_sent'    => !empty($r['final_report_sent_at']),
                    'final_report_overdue' => $isSig && $isOpen && $frDue && $now > $frDue && !$r['final_report_sent_at'],
                ],
            ];
        }

        // Statistiche totali (non solo quelle nella pagina)
        $stats = Database::fetchOne(
            'SELECT COUNT(*) as total,
                    SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count,
                    SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant
             FROM incidents WHERE organization_id = ?',
            [$orgId]
        );

        $this->jsonSuccess([
            'total'          => (int) ($stats['total'] ?? 0),
            'open'           => (int) ($stats['open_count'] ?? 0),
            'significant'    => (int) ($stats['significant'] ?? 0),
            'csirt_overdue'  => $csirtOverdue,
            'fetched'        => count($incidents),
            'incidents'      => $incidents,
        ]);
    }

    /**
     * GET /api/services/training
     * Formazione NIS2 Art.20 — corsi, completamento, compliance board.
     * Utile per lg231 per evidenza OdV Pillar 4 (Formazione).
     */
    public function training(): void
    {
        $this->requireApiKey('read:all');
        $orgId = $this->currentOrgId;

        // Corsi dell'org + corsi globali (org_id IS NULL)
        $courses = Database::fetchAll(
            'SELECT id, title, target_role, nis2_article, is_mandatory, duration_minutes
             FROM training_courses
             WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1
             ORDER BY is_mandatory DESC, target_role, title',
            [$orgId]
        );

        if (empty($courses)) {
            $this->jsonSuccess([
                'courses_total' => 0, 'mandatory_total' => 0,
                'assignments_total' => 0, 'assignments_completed' => 0,
                'overall_completion_rate' => 0, 'board_completion_rate' => 0,
                'art20_compliance' => false, 'courses' => [],
            ]);
            return;
        }

        $courseIds = array_column($courses, 'id');
        $placeholders = implode(',', array_fill(0, count($courseIds), '?'));

        // Aggregazione assignments per corso
        $assignRows = Database::fetchAll(
            "SELECT course_id,
                    COUNT(*) as total,
                    SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed
             FROM training_assignments
             WHERE organization_id = ? AND course_id IN ({$placeholders})
             GROUP BY course_id",
            array_merge([$orgId], $courseIds)
        );
        $assignMap = [];
        foreach ($assignRows as $row) {
            $assignMap[(int)$row['course_id']] = ['total' => (int)$row['total'], 'completed' => (int)$row['completed']];
        }

        // Completion board members: corsi mandatory × utenti board
        $boardAssign = Database::fetchOne(
            "SELECT COUNT(*) as total,
                    SUM(CASE WHEN ta.status = 'completed' THEN 1 ELSE 0 END) as completed
             FROM training_assignments ta
             JOIN users u ON u.id = ta.user_id
             JOIN user_organizations uo ON uo.user_id = u.id AND uo.organization_id = ta.organization_id
             WHERE ta.organization_id = ? AND uo.role = 'board_member'",
            [$orgId]
        );

        $totalAssign = 0;
        $totalCompleted = 0;
        $mandatoryBoardTotal = 0;
        $mandatoryBoardCompleted = 0;
        $courseList = [];

        foreach ($courses as $c) {
            $cid  = (int) $c['id'];
            $a    = $assignMap[$cid] ?? ['total' => 0, 'completed' => 0];
            $rate = $a['total'] > 0 ? (int) round($a['completed'] / $a['total'] * 100) : 0;

            $totalAssign    += $a['total'];
            $totalCompleted += $a['completed'];

            if ($c['is_mandatory'] && $c['target_role'] === 'board_member') {
                $mandatoryBoardTotal     += $a['total'];
                $mandatoryBoardCompleted += $a['completed'];
            }

            $courseList[] = [
                'id'               => $cid,
                'title'            => $c['title'],
                'target_role'      => $c['target_role'],
                'nis2_article'     => $c['nis2_article'],
                'is_mandatory'     => (bool) $c['is_mandatory'],
                'duration_minutes' => (int) ($c['duration_minutes'] ?? 0),
                'assigned'         => $a['total'],
                'completed'        => $a['completed'],
                'completion_rate'  => $rate,
            ];
        }

        $overallRate = $totalAssign > 0 ? (int) round($totalCompleted / $totalAssign * 100) : 0;
        $boardRate   = ((int)($boardAssign['total'] ?? 0)) > 0
            ? (int) round((int)($boardAssign['completed'] ?? 0) / (int)$boardAssign['total'] * 100)
            : 0;
        $art20ok     = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal;

        // Corsi mandatory non conformi (completion_rate < 100%) → evidenza OdV Pillar 4
        $nonCompliant = array_values(array_filter($courseList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100));

        $this->jsonSuccess([
            'courses_total'           => count($courses),
            'mandatory_total'         => count(array_filter($courses, fn($c) => $c['is_mandatory'])),
            'assignments_total'       => $totalAssign,
            'assignments_completed'   => $totalCompleted,
            'overall_completion_rate' => $overallRate,
            'board_completion_rate'   => $boardRate,
            'art20_compliance'        => $art20ok,
            'non_compliant_mandatory' => $nonCompliant,
            'courses'                 => $courseList,
        ]);
    }

    /**
     * GET /api/services/deadlines
     * Scadenze aggregate da 4 sorgenti: incidenti CSIRT, revisioni controlli,
     * assessment fornitori, training assignments.
     * ?days=30   finestra (default 30, max 365)
     */
    public function deadlines(): void
    {
        $this->requireApiKey('read:all');
        $orgId = $this->currentOrgId;

        $days    = min((int) ($this->getParam('days') ?: 30), 365);
        $now     = time();
        $horizon = $now + ($days * 86400);
        $deadlines = [];

        // ── 1. Notifiche CSIRT incidenti significativi aperti ──────────────
        $openInc = Database::fetchAll(
            'SELECT id, title, severity,
                    early_warning_due, early_warning_sent_at,
                    notification_due, notification_sent_at,
                    final_report_due, final_report_sent_at
             FROM incidents
             WHERE organization_id = ? AND is_significant = 1
               AND status NOT IN ("closed","post_mortem")',
            [$orgId]
        );

        foreach ($openInc as $inc) {
            $steps = [
                ['subtype' => 'early_warning_24h',  'due' => $inc['early_warning_due'],  'sent' => $inc['early_warning_sent_at']],
                ['subtype' => 'notification_72h',   'due' => $inc['notification_due'],   'sent' => $inc['notification_sent_at']],
                ['subtype' => 'final_report_30d',   'due' => $inc['final_report_due'],   'sent' => $inc['final_report_sent_at']],
            ];
            foreach ($steps as $step) {
                if (!$step['due'] || $step['sent']) continue;
                $dueTs = strtotime($step['due']);
                if ($dueTs > $horizon) continue;
                $hoursLeft = (int) round(($dueTs - $now) / 3600);
                $deadlines[] = [
                    'type'          => 'incident_notification',
                    'subtype'       => $step['subtype'],
                    'title'         => $step['subtype'] . ': ' . $inc['title'],
                    'due_date'      => $step['due'],
                    'overdue'       => $dueTs < $now,
                    'hours_remaining' => $hoursLeft,
                    'priority'      => 'critical',
                    'reference_id'  => (int) $inc['id'],
                    'nis2_article'  => 'Art.23',
                ];
            }
        }

        // ── 2. Revisioni controlli compliance ─────────────────────────────
        $controls = Database::fetchAll(
            'SELECT id, control_code, title, next_review_date, status
             FROM compliance_controls
             WHERE organization_id = ? AND next_review_date IS NOT NULL
               AND status != "verified"',
            [$orgId]
        );
        foreach ($controls as $c) {
            $dueTs = strtotime($c['next_review_date']);
            if ($dueTs > $horizon) continue;
            $hoursLeft = (int) round(($dueTs - $now) / 3600);
            $deadlines[] = [
                'type'          => 'control_review',
                'subtype'       => $c['status'],
                'title'         => 'Revisione: ' . $c['title'],
                'due_date'      => $c['next_review_date'],
                'overdue'       => $dueTs < $now,
                'hours_remaining' => $hoursLeft,
                'priority'      => 'medium',
                'reference_id'  => (int) $c['id'],
                'nis2_article'  => 'Art.21',
            ];
        }

        // ── 3. Assessment fornitori ────────────────────────────────────────
        $suppliers = Database::fetchAll(
            'SELECT id, name, criticality, next_assessment_date
             FROM suppliers
             WHERE organization_id = ? AND deleted_at IS NULL
               AND next_assessment_date IS NOT NULL',
            [$orgId]
        );
        foreach ($suppliers as $s) {
            $dueTs = strtotime($s['next_assessment_date']);
            if ($dueTs > $horizon) continue;
            $hoursLeft = (int) round(($dueTs - $now) / 3600);
            $priority  = in_array($s['criticality'], ['critical', 'high']) ? 'high' : 'medium';
            $deadlines[] = [
                'type'          => 'supplier_assessment',
                'subtype'       => $s['criticality'],
                'title'         => 'Valutazione fornitore: ' . $s['name'],
                'due_date'      => $s['next_assessment_date'],
                'overdue'       => $dueTs < $now,
                'hours_remaining' => $hoursLeft,
                'priority'      => $priority,
                'reference_id'  => (int) $s['id'],
                'nis2_article'  => 'Art.21.2.d',
            ];
        }

        // ── 4. Training assignments scaduti/in scadenza ────────────────────
        $trainings = Database::fetchAll(
            'SELECT ta.id, tc.title, ta.due_date, ta.status
             FROM training_assignments ta
             JOIN training_courses tc ON tc.id = ta.course_id
             WHERE ta.organization_id = ? AND ta.status NOT IN ("completed")
               AND ta.due_date IS NOT NULL',
            [$orgId]
        );
        foreach ($trainings as $t) {
            $dueTs = strtotime($t['due_date']);
            if ($dueTs > $horizon) continue;
            $hoursLeft = (int) round(($dueTs - $now) / 3600);
            $deadlines[] = [
                'type'          => 'training_assignment',
                'subtype'       => $t['status'],
                'title'         => 'Formazione: ' . $t['title'],
                'due_date'      => $t['due_date'],
                'overdue'       => $dueTs < $now,
                'hours_remaining' => $hoursLeft,
                'priority'      => 'low',
                'reference_id'  => (int) $t['id'],
                'nis2_article'  => 'Art.20',
            ];
        }

        // Ordina: overdue + urgenti prima
        usort($deadlines, function ($a, $b) {
            if ($a['overdue'] !== $b['overdue']) return $b['overdue'] <=> $a['overdue'];
            return strtotime($a['due_date']) <=> strtotime($b['due_date']);
        });

        $overdue    = count(array_filter($deadlines, fn($d) => $d['overdue']));
        $due7days   = count(array_filter($deadlines, fn($d) => !$d['overdue'] && strtotime($d['due_date']) <= $now + 7 * 86400));
        $due30days  = count(array_filter($deadlines, fn($d) => !$d['overdue']));

        $this->jsonSuccess([
            'days_horizon'  => $days,
            'overdue'       => $overdue,
            'due_7_days'    => $due7days,
            'due_30_days'   => $due30days,
            'total'         => count($deadlines),
            'deadlines'     => $deadlines,
        ]);
    }

    // ══════════════════════════════════════════════════════════════════════
    // INGESTION INCIDENTI (P1) — apertura automatica incidenti Art.23 da
    // alert SIEM/SOC/EDR. Scope: ingest:incidents. Dedup su external_ref.
    // ══════════════════════════════════════════════════════════════════════

    /**
     * POST /api/services/incidents-ingest
     * Auth: X-API-Key con scope ingest:incidents
     * Body JSON (alert esterno):
     *   {
     *     "external_id": "splunk-8842",        // per dedup (consigliato)
     *     "source": "siem|soc|edr|api|email",
     *     "source_system": "Splunk Enterprise",
     *     "title": "Brute force su VPN gateway",
     *     "description": "...",
     *     "severity": "high" | 8 | "P1",       // mappata su low/medium/high/critical
     *     "detected_at": "2026-05-30T05:00:00Z",
     *     "classification": "cyber_attack",    // opzionale (altrimenti AI)
     *     "ai_classify": true,                 // default true
     *     "affected_services": "...", "affected_users_count": 0,
     *     "cross_border_impact": false, "malicious_action": true
     *   }
     */
    public function ingestIncident(): void
    {
        $this->requireApiKey('ingest:incidents');
        $orgId = $this->currentOrgId;
        $body  = $this->getJsonBody();

        $title = trim((string) ($body['title'] ?? ''));
        if ($title === '') {
            $this->jsonError('Campo "title" obbligatorio', 422, 'VALIDATION');
        }

        // Sorgente
        $source = strtolower((string) ($body['source'] ?? 'api'));
        if (!in_array($source, ['siem', 'soc', 'edr', 'api', 'email'], true)) {
            $source = 'api';
        }
        $sourceSystem = isset($body['source_system'])
            ? substr(trim((string) $body['source_system']), 0, 120) : null;

        // Dedup su external_id (org + ref)
        $externalId = isset($body['external_id']) ? substr(trim((string) $body['external_id']), 0, 190) : '';
        if ($externalId !== '') {
            $existing = Database::fetchOne(
                'SELECT id, incident_code FROM incidents WHERE organization_id = ? AND external_ref = ?',
                [$orgId, $externalId]
            );
            if ($existing) {
                $this->jsonSuccess([
                    'id'            => (int) $existing['id'],
                    'incident_code' => $existing['incident_code'],
                    'deduplicated'  => true,
                ], 'Alert già ingerito (dedup su external_id)', 200);
                return;
            }
        }
        $externalRef = $externalId !== '' ? $externalId : null;

        $description = trim((string) ($body['description'] ?? ''));
        $ts = strtotime((string) ($body['detected_at'] ?? 'now'));
        $detectedAt = date('Y-m-d H:i:s', $ts ?: time());

        $validClass = ['cyber_attack', 'data_breach', 'system_failure', 'human_error', 'natural_disaster', 'supply_chain', 'other'];
        $validSev   = ['low', 'medium', 'high', 'critical'];

        $classification = in_array(($body['classification'] ?? ''), $validClass, true) ? $body['classification'] : null;
        $severity       = $this->mapSeverity($body['severity'] ?? null);
        $isSignificant  = array_key_exists('is_significant', $body) ? (bool) $body['is_significant'] : null;
        $isType         = null;
        $aiUsed         = false;
        $aiReason       = null;
        $org            = null;

        // Classificazione AI (best-effort) se mancano dati chiave
        $aiClassify = $body['ai_classify'] ?? true;
        if ($aiClassify && ($classification === null || $severity === null || $isSignificant === null)) {
            try {
                require_once __DIR__ . '/../services/AIService.php';
                $org = Database::fetchOne('SELECT sector, entity_type FROM organizations WHERE id = ?', [$orgId]);
                $ai  = (new AIService())->classifyIncident($title, $description ?: $title, $org ?: []);
                if (is_array($ai)) {
                    $aiUsed = true;
                    if ($classification === null && in_array(($ai['classification'] ?? ''), $validClass, true)) {
                        $classification = $ai['classification'];
                    }
                    if ($severity === null && in_array(($ai['severity'] ?? ''), $validSev, true)) {
                        $severity = $ai['severity'];
                    }
                    if ($isSignificant === null && isset($ai['is_significant'])) {
                        $isSignificant = (bool) $ai['is_significant'];
                    }
                    if (!empty($ai['nis2_incident_type']) && $ai['nis2_incident_type'] !== 'none') {
                        $isType = $ai['nis2_incident_type'];
                    }
                    $aiReason = $ai['significance_reason'] ?? null;
                }
            } catch (Throwable $e) {
                error_log('[INGEST] AI classify error: ' . $e->getMessage());
            }
        }

        // Default sensati per alert da sistemi di sicurezza
        if ($classification === null) {
            $classification = in_array($source, ['siem', 'soc', 'edr'], true) ? 'cyber_attack' : 'other';
        }
        if ($severity === null)      $severity = 'medium';
        if ($isSignificant === null) $isSignificant = false;

        // Regime obblighi NIS2 (Determina ACN 164179/2025) — coerente con IncidentController::create
        if ($org === null) {
            $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]);
        }
        $entityObligation = ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essential' : 'important';
        $validIs = $entityObligation === 'essential' ? ['IS-1', 'IS-2', 'IS-3', 'IS-4'] : ['IS-1', 'IS-2', 'IS-3'];
        if ($isType !== null && !in_array($isType, $validIs, true)) {
            $isType = null;
        }

        $data = [
            'organization_id'      => $orgId,
            'incident_code'        => $this->generateCode('INC'),
            'title'                => $title,
            'description'          => $description ?: null,
            'classification'       => $classification,
            'source'               => $source,
            'source_system'        => $sourceSystem,
            'external_ref'         => $externalRef,
            'severity'             => $severity,
            'is_significant'       => $isSignificant ? 1 : 0,
            'nis2_incident_type'   => $isType,
            'entity_obligation'    => $entityObligation,
            'detected_at'          => $detectedAt,
            'affected_services'    => $body['affected_services'] ?? null,
            'affected_users_count' => isset($body['affected_users_count']) ? (int) $body['affected_users_count'] : null,
            'cross_border_impact'  => !empty($body['cross_border_impact']) ? 1 : 0,
            'malicious_action'     => !empty($body['malicious_action']) ? 1 : (in_array($source, ['siem', 'soc', 'edr'], true) ? 1 : 0),
            'reported_by'          => null,
            'assigned_to'          => null,
        ];

        if ($isSignificant) {
            $t = strtotime($detectedAt);
            $data['early_warning_due'] = date('Y-m-d H:i:s', $t + 24 * 3600);
            $data['notification_due']  = date('Y-m-d H:i:s', $t + 72 * 3600);
            $data['final_report_due']  = date('Y-m-d H:i:s', $t + 30 * 86400);
        }

        // Insert resiliente: retry su collisione incident_code (random 6 cifre),
        // e gestione graceful della race su external_ref (dedup concorrente → 200).
        $incidentId = null;
        for ($attempt = 0; $attempt < 5; $attempt++) {
            try {
                $incidentId = Database::insert('incidents', $data);
                break;
            } catch (Throwable $e) {
                $msg = $e->getMessage();
                if (stripos($msg, 'Duplicate') === false && stripos($msg, '1062') === false) {
                    throw $e; // errore non-duplicate: propaga
                }
                // Race su external_ref: un altro processo ha gia ingerito lo stesso alert
                if ($externalRef !== null && stripos($msg, 'uq_incident_external_ref') !== false) {
                    $dup = Database::fetchOne(
                        'SELECT id, incident_code FROM incidents WHERE organization_id = ? AND external_ref = ?',
                        [$orgId, $externalRef]
                    );
                    if ($dup) {
                        $this->jsonSuccess([
                            'id'            => (int) $dup['id'],
                            'incident_code' => $dup['incident_code'],
                            'deduplicated'  => true,
                        ], 'Alert già ingerito (dedup concorrente)', 200);
                        return;
                    }
                }
                // Altrimenti: collisione su incident_code → rigenera e riprova
                $data['incident_code'] = $this->generateCode('INC');
            }
        }
        if ($incidentId === null) {
            $this->jsonError('Impossibile registrare l\'incidente (codice duplicato)', 500, 'INSERT_FAILED');
        }

        Database::insert('incident_timeline', [
            'incident_id' => $incidentId,
            'event_type'  => 'detection',
            'description' => 'Incidente ingerito automaticamente da ' . strtoupper($source)
                . ($sourceSystem ? " ({$sourceSystem})" : '')
                . ($aiReason ? '. Classificazione AI: ' . $aiReason : ''),
            'created_by'  => null,
        ]);

        // Dispatch webhook (best-effort)
        try {
            require_once __DIR__ . '/../services/WebhookService.php';
            $incident = array_merge($data, ['id' => $incidentId]);
            $wh = new WebhookService();
            $wh->dispatch($orgId, 'incident.created', WebhookService::incidentPayload($incident, 'created'));
            if ($isSignificant) {
                $wh->dispatch($orgId, 'incident.significant', WebhookService::incidentPayload($incident, 'significant'));
            }
        } catch (Throwable $e) {
            error_log('[INGEST][WEBHOOK] ' . $e->getMessage());
        }

        $this->jsonSuccess([
            'id'             => $incidentId,
            'incident_code'  => $data['incident_code'],
            'deduplicated'   => false,
            'source'         => $source,
            'classification' => $classification,
            'severity'       => $severity,
            'is_significant' => $isSignificant,
            'ai_classified'  => $aiUsed,
            'deadlines'      => $isSignificant ? [
                'early_warning' => $data['early_warning_due'],
                'notification'  => $data['notification_due'],
                'final_report'  => $data['final_report_due'],
            ] : null,
        ], 'Incidente ingerito', 201);
    }

    /**
     * Normalizza la severity di un alert esterno → enum incidents.
     * Accetta stringhe (high/critical/P1/sev2/warning…) o numeri (CVSS 0-10, scala 1-5).
     */
    private function mapSeverity($val): ?string
    {
        if ($val === null || $val === '') {
            return null;
        }
        if (is_numeric($val)) {
            $n = (float) $val;
            if ($n <= 5) { // scala 1-5
                return $n >= 4 ? 'critical' : ($n >= 3 ? 'high' : ($n >= 2 ? 'medium' : 'low'));
            }
            return $n >= 9 ? 'critical' : ($n >= 7 ? 'high' : ($n >= 4 ? 'medium' : 'low')); // CVSS 0-10
        }
        $s = strtolower(trim((string) $val));
        $map = [
            'critical' => 'critical', 'crit' => 'critical', 'sev1' => 'critical', 'p1' => 'critical', 'emergency' => 'critical', 'fatal' => 'critical',
            'high' => 'high', 'severe' => 'high', 'sev2' => 'high', 'p2' => 'high', 'error' => 'high',
            'medium' => 'medium', 'moderate' => 'medium', 'warning' => 'medium', 'warn' => 'medium', 'sev3' => 'medium', 'p3' => 'medium',
            'low' => 'low', 'info' => 'low', 'informational' => 'low', 'notice' => 'low', 'sev4' => 'low', 'p4' => 'low', 'p5' => 'low',
        ];
        return $map[$s] ?? null;
    }

    // ══════════════════════════════════════════════════════════════════════
    // EVIDENCE AUTOMATION + CONTINUOUS CONTROL MONITORING (P1)
    // Colma il gap vs Vanta/Drata: i connettori (M365, AWS, EDR, ...) inviano
    // evidenze automatiche per i controlli; lo stato/freschezza è ricalcolato.
    // ══════════════════════════════════════════════════════════════════════

    /**
     * POST /api/services/evidence-ingest
     * Auth: X-API-Key con scope ingest:evidence
     * Body JSON (singola evidenza o batch via "evidences":[...]):
     *   {
     *     "control_code": "ART21_2_A",          // controllo target (obbligatorio)
     *     "source": "m365|google|aws|azure|idp|edr|siem|api",
     *     "source_system": "Microsoft 365",
     *     "status": "pass|fail|warning|not_applicable",
     *     "summary": "MFA attiva per 142/142 utenti admin",
     *     "external_ref": "m365-mfa-check-2026-05-30",  // idempotenza
     *     "payload": { ... },                    // dettaglio raw (opzionale)
     *     "collected_at": "2026-05-30T06:00:00Z",
     *     "valid_for_days": 7                     // default freshness_days del controllo
     *   }
     */
    public function ingestEvidence(): void
    {
        $this->requireApiKey('ingest:evidence');
        $orgId = $this->currentOrgId;
        $body  = $this->getJsonBody();

        // Supporta batch
        $items = isset($body['evidences']) && is_array($body['evidences']) ? $body['evidences'] : [$body];
        if (count($items) > 200) {
            $this->jsonError('Massimo 200 evidenze per richiesta', 422, 'BATCH_TOO_LARGE');
        }

        $validSource = ['m365', 'google', 'aws', 'azure', 'idp', 'edr', 'siem', 'api', 'manual'];
        $validStatus = ['pass', 'fail', 'warning', 'not_applicable'];

        $results = [];
        $touchedControls = [];
        $accepted = 0;

        foreach ($items as $i => $ev) {
            if (!is_array($ev)) { $results[] = ['index' => $i, 'ok' => false, 'error' => 'not_an_object']; continue; }
            $code = trim((string) ($ev['control_code'] ?? ''));
            if ($code === '') { $results[] = ['index' => $i, 'ok' => false, 'error' => 'control_code mancante']; continue; }

            $source = strtolower((string) ($ev['source'] ?? 'api'));
            if (!in_array($source, $validSource, true)) $source = 'api';
            $status = strtolower((string) ($ev['status'] ?? 'pass'));
            if (!in_array($status, $validStatus, true)) $status = 'pass';

            $collectedTs = strtotime((string) ($ev['collected_at'] ?? 'now')) ?: time();
            $collectedAt = date('Y-m-d H:i:s', $collectedTs);

            // freshness: dal body o dal controllo (default 30)
            $ctl = Database::fetchOne(
                'SELECT id, freshness_days FROM compliance_controls WHERE organization_id = ? AND control_code = ?',
                [$orgId, $code]
            );
            $validDays  = isset($ev['valid_for_days']) ? max(1, (int) $ev['valid_for_days'])
                        : (int) ($ctl['freshness_days'] ?? 30);
            $validUntil = date('Y-m-d H:i:s', $collectedTs + $validDays * 86400);

            $externalRef = isset($ev['external_ref']) ? substr(trim((string) $ev['external_ref']), 0, 190) : null;
            $payload     = isset($ev['payload']) ? json_encode($ev['payload'], JSON_UNESCAPED_UNICODE) : null;
            $summary     = isset($ev['summary']) ? substr(trim((string) $ev['summary']), 0, 255) : null;
            $sourceSystem = isset($ev['source_system']) ? substr(trim((string) $ev['source_system']), 0, 120) : null;

            $data = [
                'organization_id' => $orgId,
                'control_code'    => $code,
                'source'          => $source,
                'source_system'   => $sourceSystem,
                'status'          => $status,
                'summary'         => $summary,
                'payload'         => $payload,
                'external_ref'    => $externalRef,
                'collected_at'    => $collectedAt,
                'valid_until'     => $validUntil,
            ];

            // Upsert idempotente su (org, control_code, external_ref)
            try {
                if ($externalRef !== null) {
                    $existing = Database::fetchOne(
                        'SELECT id FROM control_evidence_auto WHERE organization_id = ? AND control_code = ? AND external_ref = ?',
                        [$orgId, $code, $externalRef]
                    );
                    if ($existing) {
                        Database::query(
                            'UPDATE control_evidence_auto SET source=?, source_system=?, status=?, summary=?, payload=?, collected_at=?, valid_until=? WHERE id=?',
                            [$source, $sourceSystem, $status, $summary, $payload, $collectedAt, $validUntil, $existing['id']]
                        );
                        $eviId = (int) $existing['id'];
                    } else {
                        $eviId = Database::insert('control_evidence_auto', $data);
                    }
                } else {
                    $eviId = Database::insert('control_evidence_auto', $data);
                }
            } catch (Throwable $e) {
                $results[] = ['index' => $i, 'ok' => false, 'error' => 'db_error'];
                error_log('[EVIDENCE] ' . $e->getMessage());
                continue;
            }

            $accepted++;
            $results[] = ['index' => $i, 'ok' => true, 'id' => $eviId, 'control_code' => $code, 'control_exists' => (bool) $ctl];
            if ($ctl) $touchedControls[$code] = true;
        }

        // Ricalcola lo stato di monitoraggio dei controlli toccati
        $monitoring = [];
        foreach (array_keys($touchedControls) as $code) {
            $monitoring[$code] = $this->recomputeControlMonitoring($orgId, $code);
        }

        $this->jsonSuccess([
            'accepted'          => $accepted,
            'total'             => count($items),
            'results'           => $results,
            'monitoring_update' => $monitoring,
        ], 'Evidenze elaborate', 201);
    }

    /**
     * Ricalcola monitoring_status + last_checked_at di un controllo in base
     * all'ultima evidenza automatica. Restituisce lo stato calcolato.
     */
    private function recomputeControlMonitoring(int $orgId, string $code): string
    {
        $last = Database::fetchOne(
            'SELECT status, collected_at, valid_until FROM control_evidence_auto
             WHERE organization_id = ? AND control_code = ?
             ORDER BY collected_at DESC LIMIT 1',
            [$orgId, $code]
        );
        if (!$last) return 'not_monitored';

        $now = time();
        $isStale = $last['valid_until'] && strtotime($last['valid_until']) < $now;

        if ($isStale) {
            $monStatus = 'stale';
        } elseif ($last['status'] === 'fail') {
            $monStatus = 'failing';
        } elseif ($last['status'] === 'warning') {
            $monStatus = 'warning';
        } else { // pass / not_applicable
            $monStatus = 'healthy';
        }

        Database::query(
            'UPDATE compliance_controls SET monitoring_status = ?, last_checked_at = ? WHERE organization_id = ? AND control_code = ?',
            [$monStatus, $last['collected_at'], $orgId, $code]
        );
        return $monStatus;
    }

    /**
     * POST /api/services/assets-ingest
     * Auth: X-API-Key con scope ingest:assets
     * Import asset da CMDB/cloud con scoring automatico GV.OC-04.
     * Body: { "source":"cmdb|aws|azure", "assets":[ {...}, ... ] }
     */
    public function ingestAssets(): void
    {
        $this->requireApiKey('ingest:assets');
        $body   = $this->getJsonBody();
        $source = strtolower((string) ($body['source'] ?? 'cmdb'));
        $items  = $body['assets'] ?? null;
        if (!is_array($items) || !$items) {
            $this->jsonError('Campo "assets" (array) obbligatorio', 422, 'VALIDATION');
        }
        require_once __DIR__ . '/AssetController.php';
        $result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null);
        $this->jsonSuccess($result, 'Asset importati', 201);
    }

    /**
     * GET /api/services/controls-monitoring
     * Auth: X-API-Key con scope read:compliance
     * Stato del monitoraggio continuo: controlli con freschezza evidenza
     * (healthy/warning/stale/failing/not_monitored).
     */
    public function controlsMonitoring(): void
    {
        $this->requireApiKey('read:compliance');
        $orgId = $this->currentOrgId;

        // Auto-stale: controlli la cui ultima evidenza è scaduta vanno marcati stale.
        // Degrada con grazia se la tabella evidenze non esiste (coerente con AuditController).
        try {
            Database::query(
                "UPDATE compliance_controls cc
                 LEFT JOIN (
                    SELECT t.control_code, t.valid_until, t.status
                    FROM control_evidence_auto t
                    JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto
                          WHERE organization_id = ? GROUP BY control_code) m
                      ON m.control_code = t.control_code AND m.mx = t.collected_at
                    WHERE t.organization_id = ?
                 ) last ON last.control_code = cc.control_code
                 SET cc.monitoring_status = 'stale'
                 WHERE cc.organization_id = ?
                   AND cc.monitoring_status NOT IN ('not_monitored')
                   AND last.valid_until IS NOT NULL AND last.valid_until < NOW()",
                [$orgId, $orgId, $orgId]
            );
        } catch (Throwable $e) {
            error_log('[CCM-services] ' . $e->getMessage());
        }

        $rows = Database::fetchAll(
            'SELECT control_code, title, status, monitoring_status, last_checked_at, freshness_days, implementation_percentage
             FROM compliance_controls WHERE organization_id = ? ORDER BY control_code',
            [$orgId]
        );

        $summary = ['healthy' => 0, 'warning' => 0, 'stale' => 0, 'failing' => 0, 'not_monitored' => 0];
        foreach ($rows as $r) {
            $ms = $r['monitoring_status'] ?? 'not_monitored';
            if (isset($summary[$ms])) $summary[$ms]++;
        }
        $monitored = count($rows) - $summary['not_monitored'];

        $this->jsonSuccess([
            'total_controls'   => count($rows),
            'monitored'        => $monitored,
            'coverage_percent' => count($rows) ? (int) round($monitored * 100 / count($rows)) : 0,
            'summary'          => $summary,
            'controls'         => $rows,
        ]);
    }
}
