<?php

declare(strict_types=1);

namespace App\Services\EntraAdminRoles;

use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService;
use Carbon\CarbonImmutable;

final class EntraAdminRolesFindingGenerator
{
    private const int GA_THRESHOLD = 5; // TODO: move to settings when configurable thresholds are implemented

    /** @var array<int, array<string, mixed>> */
    private array $alertEvents = [];

    public function __construct(
        private readonly HighPrivilegeRoleCatalog $catalog,
        private readonly ?FindingSlaPolicy $slaPolicy = null,
        private readonly ?FindingWorkflowService $findingWorkflowService = null,
    ) {}

    /**
     * Generate/upsert/auto-resolve findings based on report payload data.
     */
    public function generate(
        ManagedEnvironment $tenant,
        array $reportPayload,
        ?OperationRun $operationRun = null,
    ): EntraAdminRolesFindingResult {
        $this->alertEvents = [];

        $created = 0;
        $resolved = 0;
        $reopened = 0;
        $unchanged = 0;

        $roleAssignments = is_array($reportPayload['role_assignments'] ?? null) ? $reportPayload['role_assignments'] : [];
        $roleDefinitions = is_array($reportPayload['role_definitions'] ?? null) ? $reportPayload['role_definitions'] : [];
        $measuredAt = (string) ($reportPayload['measured_at'] ?? CarbonImmutable::now('UTC')->toIso8601String());
        $observedAt = $this->resolveObservedAt($measuredAt);

        $roleDefMap = $this->buildRoleDefMap($roleDefinitions);

        $currentFingerprints = [];
        $gaCount = 0;
        $gaPrincipals = [];

        foreach ($roleAssignments as $assignment) {
            $roleDefId = (string) ($assignment['roleDefinitionId'] ?? '');
            $roleDef = $roleDefMap[$roleDefId] ?? null;
            $templateId = (string) ($roleDef['templateId'] ?? $roleDefId);
            $displayName = $roleDef !== null ? (string) ($roleDef['displayName'] ?? '') : null;

            $severity = $this->catalog->classify($templateId, $displayName);

            if ($severity === null) {
                continue;
            }

            $principalId = (string) ($assignment['principalId'] ?? '');
            $scopeId = (string) ($assignment['directoryScopeId'] ?? '/');
            $principal = $assignment['principal'] ?? [];

            $fingerprint = $this->individualFingerprint($tenant, $templateId, $principalId, $scopeId);
            $currentFingerprints[] = $fingerprint;

            $evidence = $this->buildEvidence($assignment, $roleDef, $principal, $severity, $measuredAt);

            $result = $this->upsertFinding(
                tenant: $tenant,
                fingerprint: $fingerprint,
                severity: $severity,
                evidence: $evidence,
                principalId: $principalId,
                roleDefId: $roleDefId,
                observedAt: $observedAt,
                operationRun: $operationRun,
            );

            match ($result) {
                'created' => $created++,
                'reopened' => $reopened++,
                default => $unchanged++,
            };

            if (in_array($result, ['created', 'reopened'], true) && in_array($severity, [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL], true)) {
                $this->produceAlertEvent($tenant, $fingerprint, $evidence);
            }

            if ($this->catalog->isGlobalAdministrator($templateId, $displayName)) {
                $gaCount++;
                $gaPrincipals[] = [
                    'display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
                    'type' => $this->resolvePrincipalType($principal),
                    'id' => $principalId,
                ];
            }
        }

        // Aggregate "Too many Global Admins" finding
        $resolved += $this->handleGaAggregate($tenant, $gaCount, $gaPrincipals, $currentFingerprints, $observedAt, $operationRun, $created, $reopened);

        // Auto-resolve stale findings
        $resolved += $this->resolveStaleFindings($tenant, $currentFingerprints, $observedAt);

        return new EntraAdminRolesFindingResult(
            created: $created,
            resolved: $resolved,
            reopened: $reopened,
            unchanged: $unchanged,
            alertEventsProduced: count($this->alertEvents),
        );
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function getAlertEvents(): array
    {
        return $this->alertEvents;
    }

    /**
     * @return array<string, array<string, mixed>>
     */
    private function buildRoleDefMap(array $roleDefinitions): array
    {
        $map = [];

        foreach ($roleDefinitions as $def) {
            $id = (string) ($def['id'] ?? '');

            if ($id !== '') {
                $map[$id] = $def;
            }
        }

        return $map;
    }

    private function upsertFinding(
        ManagedEnvironment $tenant,
        string $fingerprint,
        string $severity,
        array $evidence,
        string $principalId,
        string $roleDefId,
        CarbonImmutable $observedAt,
        ?OperationRun $operationRun,
    ): string {
        $slaPolicy = $this->resolveSlaPolicy();

        $existing = Finding::query()
            ->where('managed_environment_id', $tenant->getKey())
            ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
            ->where('fingerprint', $fingerprint)
            ->first();

        if ($existing instanceof Finding) {
            $this->observeFinding($existing, $tenant, $observedAt, $severity);

            $existing->forceFill([
                'severity' => $severity,
                'evidence_jsonb' => $evidence,
                'current_operation_run_id' => $operationRun?->getKey(),
            ]);

            if ($existing->status === Finding::STATUS_RESOLVED) {
                $resolvedAt = $existing->resolved_at;

                if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
                    $existing->save();

                    $this->findingWorkflowService()->reopenBySystem(
                        finding: $existing,
                        tenant: $tenant,
                        reopenedAt: $observedAt,
                        operationRunId: $operationRun?->getKey(),
                    );

                    return 'reopened';
                }
            }

            // Update evidence on existing open finding
            $existing->save();

            return 'unchanged';
        }

        $slaDays = $slaPolicy->daysForSeverity($severity, $tenant);

        Finding::create([
            'managed_environment_id' => (int) $tenant->getKey(),
            'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
            'source' => 'entra.admin_roles',
            'scope_key' => hash('sha256', 'entra_admin_roles:'.$tenant->getKey()),
            'fingerprint' => $fingerprint,
            'subject_type' => 'role_assignment',
            'subject_external_id' => "{$principalId}:{$roleDefId}",
            'severity' => $severity,
            'status' => Finding::STATUS_NEW,
            'evidence_jsonb' => $evidence,
            'current_operation_run_id' => $operationRun?->getKey(),
            'first_seen_at' => $observedAt,
            'last_seen_at' => $observedAt,
            'times_seen' => 1,
            'sla_days' => $slaDays,
            'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
        ]);

        return 'created';
    }

    /**
     * Handle aggregate "Too many Global Admins" finding.
     *
     * @return int Number of resolved findings (0 or 1)
     */
    private function handleGaAggregate(
        ManagedEnvironment $tenant,
        int $gaCount,
        array $gaPrincipals,
        array &$currentFingerprints,
        CarbonImmutable $observedAt,
        ?OperationRun $operationRun,
        int &$created,
        int &$reopened,
    ): int {
        $slaPolicy = $this->resolveSlaPolicy();
        $gaFingerprint = $this->gaAggregateFingerprint($tenant);
        $currentFingerprints[] = $gaFingerprint;

        $resolved = 0;

        if ($gaCount > self::GA_THRESHOLD) {
            $evidence = [
                'count' => $gaCount,
                'threshold' => self::GA_THRESHOLD,
                'principals' => $gaPrincipals,
            ];

            $existing = Finding::query()
                ->where('managed_environment_id', $tenant->getKey())
                ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
                ->where('fingerprint', $gaFingerprint)
                ->first();

            if ($existing instanceof Finding) {
                $this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);

                $existing->forceFill([
                    'severity' => Finding::SEVERITY_HIGH,
                    'evidence_jsonb' => $evidence,
                    'current_operation_run_id' => $operationRun?->getKey(),
                ]);

                if ($existing->status === Finding::STATUS_RESOLVED) {
                    $resolvedAt = $existing->resolved_at;

                    if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
                        $existing->save();

                        $this->findingWorkflowService()->reopenBySystem(
                            finding: $existing,
                            tenant: $tenant,
                            reopenedAt: $observedAt,
                            operationRunId: $operationRun?->getKey(),
                        );

                        $reopened++;
                        $this->produceAlertEvent($tenant, $gaFingerprint, $evidence);

                        return $resolved;
                    }
                }

                $existing->save();
            } else {
                $slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant);

                Finding::create([
                    'managed_environment_id' => (int) $tenant->getKey(),
                    'finding_type' => Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES,
                    'source' => 'entra.admin_roles',
                    'scope_key' => hash('sha256', 'entra_admin_roles_ga_count:'.$tenant->getKey()),
                    'fingerprint' => $gaFingerprint,
                    'subject_type' => 'role_assignment',
                    'subject_external_id' => 'ga_aggregate',
                    'severity' => Finding::SEVERITY_HIGH,
                    'status' => Finding::STATUS_NEW,
                    'evidence_jsonb' => $evidence,
                    'current_operation_run_id' => $operationRun?->getKey(),
                    'first_seen_at' => $observedAt,
                    'last_seen_at' => $observedAt,
                    'times_seen' => 1,
                    'sla_days' => $slaDays,
                    'due_at' => $slaPolicy->dueAtForSeverity(Finding::SEVERITY_HIGH, $tenant, $observedAt),
                ]);
                $created++;
                $this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
            }
        } else {
            // Auto-resolve aggregate if threshold met
            $existing = Finding::query()
                ->where('managed_environment_id', $tenant->getKey())
                ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
                ->where('fingerprint', $gaFingerprint)
                ->whereIn('status', Finding::openStatusesForQuery())
                ->first();

            if ($existing instanceof Finding) {
                $this->findingWorkflowService()->resolveBySystem(
                    finding: $existing,
                    tenant: $tenant,
                    reason: 'ga_count_within_threshold',
                    resolvedAt: $observedAt,
                );
                $resolved++;
            }
        }

        return $resolved;
    }

    /**
     * Resolve open findings whose fingerprint is not in the current scan.
     */
    private function resolveStaleFindings(ManagedEnvironment $tenant, array $currentFingerprints, CarbonImmutable $observedAt): int
    {
        $staleFindings = Finding::query()
            ->where('managed_environment_id', $tenant->getKey())
            ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
            ->whereIn('status', Finding::openStatusesForQuery())
            ->whereNotIn('fingerprint', $currentFingerprints)
            ->get();

        $resolved = 0;

        foreach ($staleFindings as $finding) {
            if (! $finding instanceof Finding) {
                continue;
            }

            $this->findingWorkflowService()->resolveBySystem(
                finding: $finding,
                tenant: $tenant,
                reason: 'role_assignment_removed',
                resolvedAt: $observedAt,
            );
            $resolved++;
        }

        return $resolved;
    }

    private function resolveObservedAt(string $measuredAt): CarbonImmutable
    {
        $measuredAt = trim($measuredAt);

        if ($measuredAt !== '') {
            try {
                return CarbonImmutable::parse($measuredAt);
            } catch (\Throwable) {
                // Fall through.
            }
        }

        return CarbonImmutable::now('UTC');
    }

    private function resolveSlaPolicy(): FindingSlaPolicy
    {
        return $this->slaPolicy ?? app(FindingSlaPolicy::class);
    }

    private function observeFinding(Finding $finding, ManagedEnvironment $tenant, CarbonImmutable $observedAt, string $severity): void
    {
        if ($finding->first_seen_at === null) {
            $finding->first_seen_at = $observedAt;
        }

        $firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);

        $lastSeenAt = $finding->last_seen_at;
        $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;

        if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
            $finding->last_seen_at = $observedAt;
            $finding->times_seen = max(0, $timesSeen) + 1;
        } elseif ($timesSeen < 1) {
            $finding->times_seen = 1;
        }

        $slaPolicy = $this->resolveSlaPolicy();

        if ($finding->sla_days === null) {
            $finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
        }

        if ($finding->due_at === null) {
            $finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
        }
    }

    private function produceAlertEvent(ManagedEnvironment $tenant, string $fingerprint, array $evidence): void
    {
        $this->alertEvents[] = [
            'event_type' => AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH,
            'managed_environment_id' => (int) $tenant->getKey(),
            'severity' => $evidence['severity'] ?? Finding::SEVERITY_HIGH,
            'fingerprint_key' => 'entra_admin_role:'.$fingerprint,
            'title' => 'High-privilege Entra admin role detected',
            'body' => sprintf(
                'Role "%s" assigned to %s on tenant %s.',
                $evidence['role_display_name'] ?? 'Unknown',
                $evidence['principal_display_name'] ?? 'Unknown',
                $tenant->name ?? (string) $tenant->getKey(),
            ),
            'metadata' => $evidence,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function buildEvidence(array $assignment, ?array $roleDef, array $principal, string $severity, string $measuredAt): array
    {
        return [
            'role_display_name' => (string) ($roleDef['displayName'] ?? 'Unknown'),
            'principal_display_name' => (string) ($principal['displayName'] ?? 'Unknown'),
            'principal_type' => $this->resolvePrincipalType($principal),
            'principal_id' => (string) ($assignment['principalId'] ?? ''),
            'role_definition_id' => (string) ($assignment['roleDefinitionId'] ?? ''),
            'role_template_id' => (string) ($roleDef['templateId'] ?? ''),
            'directory_scope_id' => (string) ($assignment['directoryScopeId'] ?? '/'),
            'is_built_in' => (bool) ($roleDef['isBuiltIn'] ?? false),
            'severity' => $severity,
            'measured_at' => $measuredAt,
        ];
    }

    private function individualFingerprint(ManagedEnvironment $tenant, string $templateId, string $principalId, string $scopeId): string
    {
        return substr(hash('sha256', "entra_admin_role:{$tenant->getKey()}:{$templateId}:{$principalId}:{$scopeId}"), 0, 64);
    }

    private function gaAggregateFingerprint(ManagedEnvironment $tenant): string
    {
        return substr(hash('sha256', "entra_admin_role_ga_count:{$tenant->getKey()}"), 0, 64);
    }

    private function resolvePrincipalType(array $principal): string
    {
        $odataType = (string) ($principal['@odata.type'] ?? '');

        return match (true) {
            str_contains($odataType, 'user') => 'user',
            str_contains($odataType, 'group') => 'group',
            str_contains($odataType, 'servicePrincipal') => 'servicePrincipal',
            default => 'unknown',
        };
    }

    private function findingWorkflowService(): FindingWorkflowService
    {
        return $this->findingWorkflowService ?? app(FindingWorkflowService::class);
    }
}
