<?php

namespace App\Services\Probes;

use App\Models\AgentProbe;
use App\Models\Server;
use App\Models\Ticket;
use App\Services\AuditLogger;
use App\Services\Whmcs\TicketOwnershipVerifier;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;

/**
 * Queues, validates, rate-limits, and audits agent probes. The single
 * entry point for any caller (operator UI, AI flow, scheduler) — keeping
 * the security checks in one place means a new caller can't accidentally
 * skip a guard.
 *
 * Security guards (in order):
 *   1. Probe type must exist in AgentProbeRegistry.
 *   2. Parameters validated against the registry's schema.
 *   3. If ticket_id set: customer must own the target server.
 *   4. URL/domain params: must be customer-owned (extra guard beyond
 *      the regex — protects against ticket-with-no-products edge cases).
 *   5. Rate limit: 20 probes/hour per (server, probe_type).
 *      30 probes/hour per ticket. Tunable.
 *   6. Every queue/dispatch/result is audit-logged.
 */
class AgentProbeOrchestrator
{
    public const MAX_PROBES_PER_SERVER_PROBE_TYPE_HOUR = 20;
    public const MAX_PROBES_PER_TICKET_HOUR = 30;
    public const DEFAULT_EXPIRY_SECONDS = 120;   // agent must poll + return within 2 min

    public function __construct(private TicketOwnershipVerifier $owner) {}

    /**
     * Queue a probe. Validates everything before inserting.
     *
     * @throws ValidationException on bad input
     * @throws \DomainException on ownership / rate-limit failure
     */
    public function queue(
        Server $server,
        string $probeType,
        array $params = [],
        ?Ticket $ticket = null,
        ?int $requestedByUserId = null,
    ): AgentProbe {
        // Guard 1: probe type known
        $spec = AgentProbeRegistry::get($probeType);
        if (! $spec) {
            throw new \DomainException("Unknown probe type: {$probeType}");
        }

        // Guard 2: validate params against the registry schema. Extra
        // keys are rejected — we don't want a caller smuggling in
        // template placeholders we didn't declare.
        $allowed = array_keys($spec['params']);
        $extra = array_diff(array_keys($params), $allowed);
        if (! empty($extra)) {
            throw new \DomainException('Unexpected parameter(s): ' . implode(', ', $extra));
        }
        $validator = validator($params, $spec['params']);
        if ($validator->fails()) {
            throw new ValidationException($validator);
        }

        // Guard 3: ownership — if this probe is for a ticket, the
        // customer must have at least one product on the server.
        if ($ticket) {
            if (! $this->owner->ownsServer($ticket, $this->resolveWhmcsServerId($server))) {
                $this->auditDenied($ticket, $server, $probeType, 'cross_tenant');
                throw new \DomainException(
                    "Ticket #{$ticket->id}'s customer does not own server {$server->hostname}");
            }
        }

        // Guard 4: URL/domain params must point at customer-owned hosts.
        if (isset($params['url'])) {
            $host = strtolower(parse_url($params['url'], PHP_URL_HOST) ?: '');
            if (! $this->isHostOwned($host, $ticket)) {
                $this->auditDenied($ticket, $server, $probeType, 'url_not_owned', ['host' => $host]);
                throw new \DomainException("URL host '{$host}' is not owned by this customer.");
            }
        }
        if (isset($params['domain'])) {
            $d = strtolower(trim($params['domain']));
            if (! $this->isHostOwned($d, $ticket)) {
                $this->auditDenied($ticket, $server, $probeType, 'domain_not_owned', ['domain' => $d]);
                throw new \DomainException("Domain '{$d}' is not owned by this customer.");
            }
        }

        // Guard 5: rate limits.
        $svrKey = "probe:srv:{$server->id}:{$probeType}";
        if (RateLimiter::tooManyAttempts($svrKey, self::MAX_PROBES_PER_SERVER_PROBE_TYPE_HOUR)) {
            $this->auditDenied($ticket, $server, $probeType, 'rate_limit_server');
            throw new \DomainException("Rate limit: too many '{$probeType}' probes on {$server->hostname} this hour.");
        }
        RateLimiter::hit($svrKey, 3600);

        if ($ticket) {
            $tkKey = "probe:tk:{$ticket->id}";
            if (RateLimiter::tooManyAttempts($tkKey, self::MAX_PROBES_PER_TICKET_HOUR)) {
                $this->auditDenied($ticket, $server, $probeType, 'rate_limit_ticket');
                throw new \DomainException("Rate limit: too many probes on ticket #{$ticket->id} this hour.");
            }
            RateLimiter::hit($tkKey, 3600);
        }

        // Build the audit command (template + redacted params) so admins
        // can see exactly what would be run — without trusting the agent's
        // logs after the fact.
        $auditCommand = $this->renderAuditCommand($spec['template'], $params);

        $probe = AgentProbe::create([
            'server_id'            => $server->id,
            'ticket_id'            => $ticket?->id,
            'requested_by_user_id' => $requestedByUserId,
            'probe_type'           => $probeType,
            'params'               => $params ?: null,
            'status'               => AgentProbe::STATUS_PENDING,
            'queued_at'            => now(),
            'expires_at'           => now()->addSeconds(self::DEFAULT_EXPIRY_SECONDS),
            'audit_command'        => $auditCommand,
        ]);

        AuditLogger::log(
            $requestedByUserId ? 'user' : 'system',
            (string) ($requestedByUserId ?: 'orchestrator'),
            // actorLabel is required string — pass the user's identifier
            // for user-initiated calls, a descriptive label otherwise.
            (string) ($requestedByUserId ? "user:{$requestedByUserId}" : 'probe-orchestrator'),
            'agent_probe.queued', 'agent_probe', (string) $probe->id,
            ['server' => $server->hostname, 'probe_type' => $probeType,
             'ticket_id' => $ticket?->id, 'audit_command' => $auditCommand]);

        return $probe;
    }

    /**
     * Used by the operator UI / AI flow to queue a probe and wait for
     * it to come back. Returns null if it doesn't finish in $waitSec.
     */
    public function queueAndWait(
        Server $server,
        string $probeType,
        array $params = [],
        ?Ticket $ticket = null,
        ?int $requestedByUserId = null,
        int $waitSec = 30,
    ): ?AgentProbe {
        $probe = $this->queue($server, $probeType, $params, $ticket, $requestedByUserId);
        $deadline = now()->addSeconds($waitSec);
        while (now()->lt($deadline)) {
            $probe->refresh();
            if ($probe->isTerminal()) return $probe;
            usleep(500_000); // 500 ms
        }
        return $probe;   // still pending — caller can decide to wait more or display "running"
    }

    /**
     * Render the audit command with parameter values inlined. Pure
     * paper-trail use — the AGENT does its own safe substitution. Here
     * we just want a human-readable record.
     */
    private function renderAuditCommand(string $template, array $params): string
    {
        $out = $template;
        foreach ($params as $k => $v) {
            $out = str_replace('{{' . $k . '}}', "'" . addcslashes((string) $v, "'\\") . "'", $out);
        }
        return $out;
    }

    /**
     * Resolve which WHMCS server id the local Server row corresponds to.
     * Matches by hostname against the whmcs_servers mirror.
     */
    private function resolveWhmcsServerId(Server $server): int
    {
        $host = strtolower((string) $server->hostname);
        if (! $host) return 0;
        $row = \App\Models\WhmcsServer::where('hostname', $host)->first();
        return $row ? (int) $row->whmcs_id : 0;
    }

    /**
     * Is the given host (URL host or domain) inside the ticket's
     * customer's product list? Required for any probe that takes a
     * URL or domain parameter.
     */
    private function isHostOwned(string $host, ?Ticket $ticket): bool
    {
        if (! $host) return false;
        if (! $ticket) {
            // Operator-initiated, no ticket — operator already authed,
            // they're explicitly invoking the probe so we trust them.
            // (RBAC check happens at the controller.)
            return true;
        }
        return $this->owner->ownsDomain($ticket, $host);
    }

    private function auditDenied(?Ticket $ticket, Server $server, string $probeType, string $reason, array $extra = []): void
    {
        AuditLogger::log('system', 'probe-orchestrator', 'denied',
            'agent_probe.denied', 'agent_probe', null,
            array_merge([
                'reason'     => $reason,
                'server'     => $server->hostname,
                'probe_type' => $probeType,
                'ticket_id'  => $ticket?->id,
            ], $extra));
    }
}
