<?php

namespace App\Http\Controllers\Api\V1;

use App\Domain\Bridge\Actions\RegisterBridgeConnection;
use App\Domain\Bridge\Actions\TerminateBridgeConnection;
use App\Domain\Bridge\Actions\UpdateBridgeEndpoints;
use App\Domain\Bridge\Enums\BridgeConnectionStatus;
use App\Domain\Bridge\Models\BridgeConnection;
use App\Domain\Bridge\Services\BridgeRouter;
use App\Http\Controllers\Controller;
use App\Infrastructure\Bridge\BridgeRequestRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * @tags Bridge
 */
class BridgeController extends Controller
{
    /**
     * Resolve the team ID from the Sanctum token's abilities (preferred)
     * or fall back to the user's current_team_id.
     *
     * Prevents wrong-team registration when the user switches teams in the
     * web UI but the API token is scoped to a specific team.
     */
    protected function resolveTeamId(Request $request): string
    {
        $user = $request->user();
        $token = $user->currentAccessToken();

        if ($token && method_exists($token, 'getAbilities')) {
            foreach ($token->getAbilities() as $ability) {
                if (str_starts_with($ability, 'team:') && strlen($ability) > 5) {
                    return substr($ability, 5);
                }
            }
        }

        return $user->current_team_id;
    }

    /**
     * Get the current team's bridge connection status.
     *
     * Returns all connections (active + recent) with backward-compatible single-bridge fields.
     *
     * @response 200 {"data": {"connected": true, "connections": [...]}}
     */
    public function status(Request $request): JsonResponse
    {
        $teamId = $this->resolveTeamId($request);

        $connections = app(BridgeRouter::class)->allConnections($teamId);

        if ($connections->isEmpty()) {
            return response()->json(['data' => [
                'connected' => false,
                'connections' => [],
                'message' => 'No FleetQ Bridge connection found.',
            ]]);
        }

        $primary = $connections->firstWhere('status', 'connected');

        return response()->json(['data' => [
            // Backward compat: single-bridge fields from the best active connection
            'connected' => $primary !== null,
            'status' => $primary?->status->value ?? $connections->first()->status->value,
            'bridge_version' => $primary?->bridge_version,
            'session_id' => $primary?->session_id,
            'connected_at' => $primary?->connected_at?->toISOString(),
            'last_seen_at' => $primary?->last_seen_at?->toISOString(),
            'uptime' => $primary?->connected_at ? now()->diffForHumans($primary->connected_at, true) : null,
            'llm_count' => $primary?->onlineLlmCount() ?? 0,
            'agent_count' => $primary?->foundAgentCount() ?? 0,
            'llm_endpoints' => $primary?->llmEndpoints() ?? [],
            'agents' => $primary?->agents() ?? [],
            'mcp_servers' => $primary?->mcpServers() ?? [],
            // Multi-bridge: all connections
            'connections' => $connections->map(fn (BridgeConnection $c) => [
                'id' => $c->id,
                'label' => $c->label,
                'status' => $c->status->value,
                'bridge_version' => $c->bridge_version,
                'ip_address' => $c->ip_address,
                'priority' => $c->priority,
                'connected_at' => $c->connected_at?->toISOString(),
                'last_seen_at' => $c->last_seen_at?->toISOString(),
                'agents' => $c->agents(),
                'llm_endpoints' => $c->llmEndpoints(),
                'mcp_servers' => $c->mcpServers(),
            ])->values(),
            'connected_count' => $connections->filter(fn ($c) => $c->isActive())->count(),
        ]]);
    }

    /**
     * Register or refresh a bridge connection (called by the bridge daemon on connect).
     */
    public function register(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'session_id' => 'required|string|max:255',
            'bridge_version' => 'nullable|string|max:50',
            'endpoints' => 'nullable|array',
            'label' => 'nullable|string|max:100',
        ]);

        $teamId = $this->resolveTeamId($request);

        $connection = app(RegisterBridgeConnection::class)->execute(
            teamId: $teamId,
            sessionId: $validated['session_id'],
            bridgeVersion: $validated['bridge_version'] ?? null,
            endpoints: $validated['endpoints'] ?? [],
            ipAddress: $request->ip(),
            label: $validated['label'] ?? null,
        );

        return response()->json(['data' => [
            'session_id' => $connection->session_id,
            'team_id' => $teamId,
            'connected_at' => $connection->connected_at->toISOString(),
            'reverb' => [
                'app_key' => config('reverb.apps.apps.0.key'),
                'relay_url' => $this->buildReverbUrl(),
            ],
        ]], 201);
    }

    /**
     * Update the endpoints manifest (called by the bridge daemon on discovery).
     */
    public function updateEndpoints(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'session_id' => 'nullable|string|max:255',
            'endpoints' => 'nullable|array',
        ]);

        $teamId = $this->resolveTeamId($request);
        $endpoints = $validated['endpoints'] ?? [];

        // Primary: find the active connection (prefer matching session_id)
        $query = BridgeConnection::where('team_id', $teamId)->active();

        if (! empty($validated['session_id'])) {
            $connection = (clone $query)->where('session_id', $validated['session_id'])->first()
                ?? $query->orderByDesc('connected_at')->first();
        } else {
            $connection = $query->orderByDesc('connected_at')->first();
        }

        // Fallback: check any recent connection regardless of status.
        // The relay calling /endpoints proves the bridge is alive — if detect-stale
        // marked it disconnected due to a brief heartbeat gap, re-activate it.
        if (! $connection) {
            $fallbackQuery = BridgeConnection::where('team_id', $teamId)
                ->orderByDesc('connected_at');

            if (! empty($validated['session_id'])) {
                $connection = (clone $fallbackQuery)->where('session_id', $validated['session_id'])->first()
                    ?? $fallbackQuery->first();
            } else {
                $connection = $fallbackQuery->first();
            }

            // Re-activate: the bridge is clearly alive if it's sending endpoints
            if ($connection && $connection->status !== BridgeConnectionStatus::Connected) {
                $connection->update([
                    'status' => BridgeConnectionStatus::Connected,
                    'disconnected_at' => null,
                ]);
            }
        }

        if ($connection) {
            app(UpdateBridgeEndpoints::class)->execute($connection, $endpoints);
        } else {
            // The relay calls endpoints before the daemon calls register — cache in Redis
            // so register can pick it up and apply it immediately on connection creation.
            Redis::connection('bridge')->setex(
                "bridge:pending_endpoints:{$teamId}",
                60,
                json_encode($endpoints),
            );
        }

        return response()->json(['data' => ['updated' => true]]);
    }

    /**
     * Heartbeat — keeps the connection alive (called by the bridge daemon periodically).
     */
    public function heartbeat(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'session_id' => 'required|string|max:255',
        ]);

        $teamId = $this->resolveTeamId($request);

        $connection = BridgeConnection::where('team_id', $teamId)
            ->where('session_id', $validated['session_id'])
            ->first();

        if (! $connection) {
            return response()->json(['error' => 'Session not found.'], 404);
        }

        $updates = ['last_seen_at' => now()];

        // Re-activate if detect-stale marked it disconnected but bridge is clearly alive
        if ($connection->status !== BridgeConnectionStatus::Connected) {
            $updates['status'] = BridgeConnectionStatus::Connected;
            $updates['disconnected_at'] = null;
        }

        $connection->update($updates);

        return response()->json(['data' => ['alive' => true]]);
    }

    /**
     * Authenticate a Pusher/Reverb private channel subscription.
     *
     * Generates the HMAC-SHA256 auth signature manually instead of using
     * Broadcast::auth() which has guard resolution issues in API context.
     */
    public function broadcastingAuth(Request $request): JsonResponse
    {
        $socketId = $request->input('socket_id');
        $channelName = $request->input('channel_name');
        $user = $request->user();

        if (! $socketId || ! $channelName || ! $user) {
            return response()->json(['error' => 'Missing parameters'], 400);
        }

        // Authorize. Two channel patterns are accepted on the API-side broadcast auth:
        //   - private-daemon.{teamId}      — bridge daemon's reverse-tunnel control channel
        //   - private-team.{teamId}.activity — unified per-team firehose consumed by the
        //                                      desktop app and any future API-bearer client.
        // In both cases the caller's team (resolved from Sanctum token abilities, falling
        // back to current_team_id) must match the channel's teamId. The web frontend
        // continues to use Laravel's standard /broadcasting/auth via session cookies and
        // is unaffected by this method.
        if (preg_match('/^private-daemon\.(.+)$/', $channelName, $m)
            || preg_match('/^private-team\.(.+)\.activity$/', $channelName, $m)) {
            if ($this->resolveTeamId($request) !== $m[1]) {
                return response()->json(['error' => 'Forbidden'], 403);
            }
        } else {
            return response()->json(['error' => 'Unsupported channel'], 403);
        }

        // Generate Pusher auth signature: HMAC-SHA256(secret, socket_id:channel_name)
        $secret = config('reverb.apps.apps.0.secret');
        $key = config('reverb.apps.apps.0.key');
        $signature = hash_hmac('sha256', "{$socketId}:{$channelName}", $secret);

        return response()->json([
            'auth' => "{$key}:{$signature}",
        ]);
    }

    private function buildReverbUrl(): string
    {
        // Use VITE_REVERB_* (public-facing) when available, otherwise fall back to the
        // app config. Server-side REVERB_HOST may point to an internal Docker host.
        $scheme = config('app.reverb_public_scheme')
            ?: config('reverb.apps.apps.0.options.scheme', 'https');
        $host = config('app.reverb_public_host')
            ?: config('reverb.apps.apps.0.options.host');
        $port = config('app.reverb_public_port')
            ?: config('reverb.apps.apps.0.options.port', 443);
        $wsScheme = $scheme === 'https' ? 'wss' : 'ws';

        return "{$wsScheme}://{$host}:{$port}";
    }

    /**
     * Proxy an MCP tool call to a bridge-hosted MCP server.
     *
     * Two transport paths:
     *  - HTTP-mode bridges (endpoint_url set): POST directly to
     *    {endpoint_url}/mcp/{server} with the validated body and
     *    return whatever the bridge returns (must be MCP JSON-RPC shape).
     *  - Relay-binary bridges (default): RPUSH to bridge:req:{teamId} →
     *    relay binary → WebSocket → bridge daemon → MCP server, response
     *    streamed back via bridge:stream:{request_id}.
     *
     * Both paths use the same validated body shape, so callers don't have
     * to know which transport is in play.
     *
     * @response 200 {"result": {"content": [{"type": "text", "text": "..."}]}}
     */
    public function mcpCall(Request $request): Response
    {
        $validated = $request->validate([
            'server' => 'required|string|max:100',
            'method' => 'required|string|in:tools/call,tools/list',
            'params' => 'required|array',
            'timeout' => 'nullable|integer|min:1|max:300',
            // When true, opens an SSE stream to the daemon and forwards
            // bytes back to the caller verbatim (text/event-stream). Only
            // honoured for HTTP-mode bridges; relay-mode bridges ignore
            // it and return synchronous JSON as before.
            'stream' => 'nullable|boolean',
        ]);

        $teamId = $this->resolveTeamId($request);
        $serverName = $validated['server'];
        $timeout = $validated['timeout'] ?? 60;
        $stream = (bool) ($validated['stream'] ?? false);
        $requestId = Str::uuid()->toString();

        $bridge = app(BridgeRouter::class)->resolveForMcpServer($teamId, $serverName);

        if (! $bridge) {
            return response()->json([
                'error' => "No active bridge with MCP server '{$serverName}'.",
            ], 404);
        }

        Log::info('BridgeController: proxying MCP call', [
            'request_id' => $requestId,
            'team_id' => $teamId,
            'bridge_id' => $bridge->id,
            'server' => $serverName,
            'method' => $validated['method'],
            'tool' => $validated['params']['name'] ?? null,
            'transport' => $bridge->isHttpMode() ? 'http_direct' : 'relay',
        ]);

        // HTTP-tunnel-mode bridges (Cloudflare Tunnel / Tailscale Funnel /
        // ngrok / etc.): the daemon exposes an HTTP endpoint at endpoint_url.
        // Skip the Redis+relay path entirely and POST directly. Synchronous
        // request/response with no streaming for now — daemons that want
        // chunked output can keep the relay path.
        if ($bridge->isHttpMode()) {
            if ($stream) {
                return $this->mcpCallViaHttpStreaming(
                    bridge: $bridge,
                    serverName: $serverName,
                    method: $validated['method'],
                    params: $validated['params'],
                    requestId: $requestId,
                    timeout: $timeout,
                );
            }

            return $this->mcpCallViaHttp(
                bridge: $bridge,
                serverName: $serverName,
                method: $validated['method'],
                params: $validated['params'],
                requestId: $requestId,
                timeout: $timeout,
            );
        }

        if ($stream) {
            return response()->json([
                'error' => 'stream=true is only supported for HTTP-tunnel-mode bridges. '
                    .'The relay-binary path uses Redis frames and does not currently '
                    .'forward chunked output.',
            ], 400);
        }

        try {
            $registry = app(BridgeRequestRegistry::class);
            $registry->register($requestId, $teamId);

            Redis::connection('bridge')->rpush(
                "bridge:req:{$teamId}",
                json_encode([
                    'request_id' => $requestId,
                    'frame_type' => 0x0020, // FrameMcpToolCall
                    'payload' => [
                        'request_id' => $requestId,
                        'server' => $serverName,
                        'method' => $validated['method'],
                        'params' => $validated['params'],
                        'timeout' => $timeout,
                    ],
                ]),
            );

            $item = $registry->popChunk($requestId, $timeout + 10);

            if ($item === null) {
                return response()->json([
                    'error' => "MCP call timed out after {$timeout}s",
                ], 504);
            }

            $usage = $registry->getUsage($requestId);
            if (isset($usage['__error'])) {
                return response()->json([
                    'error' => $usage['__error'],
                ], 502);
            }

            // Return the raw MCP result
            $result = json_decode($item['chunk'] ?? '', true);

            return response()->json($result ?: ['result' => ['content' => [['type' => 'text', 'text' => $item['chunk'] ?? '']]]]);
        } catch (\Throwable $e) {
            Log::error('BridgeController: MCP call exception', [
                'request_id' => $requestId,
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'error' => "Bridge MCP call failed: {$e->getMessage()}",
            ], 502);
        }
    }

    /**
     * HTTP-direct path of mcpCall: POST to {endpoint_url}/mcp/{server} on
     * the bridge daemon and forward the response.
     *
     * Auth: Bearer {endpoint_secret} when present. Bridge daemons that
     * configure no secret accept anonymous calls (loopback-only deployment).
     *
     * Failure modes:
     *   - HTTP exception (DNS / connect / timeout) → 502
     *   - 4xx response from the daemon → forward status + body verbatim so
     *     callers see the original "tool not found" / "bad payload" error
     *     instead of a generic 502 wrapping
     *   - 5xx response from the daemon → 502 with status + body tail
     *     (the daemon itself is broken)
     *   - Successful 2xx → forward the parsed JSON body verbatim
     */
    private function mcpCallViaHttp(
        BridgeConnection $bridge,
        string $serverName,
        string $method,
        array $params,
        string $requestId,
        int $timeout,
    ): JsonResponse {
        $headers = ['Accept' => 'application/json'];
        if (! empty($bridge->endpoint_secret)) {
            $headers['Authorization'] = 'Bearer '.$bridge->endpoint_secret;
        }

        $url = rtrim($bridge->endpoint_url, '/').'/mcp/'.$serverName;
        $body = [
            'request_id' => $requestId,
            'method' => $method,
            'params' => $params,
            'timeout' => $timeout,
        ];

        try {
            // Add a small buffer over the per-call timeout so the HTTP layer
            // doesn't fire before the daemon's own timeout check returns.
            $response = Http::timeout($timeout + 5)
                ->withHeaders($headers)
                ->post($url, $body);
        } catch (\Throwable $e) {
            Log::error('BridgeController: HTTP-direct MCP call failed', [
                'request_id' => $requestId,
                'bridge_id' => $bridge->id,
                'endpoint_url' => $bridge->endpoint_url,
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'error' => "Bridge HTTP-direct call failed: {$e->getMessage()}",
            ], 502);
        }

        if ($response->clientError()) {
            // 4xx from the daemon: pass the daemon's status + body to the
            // caller unchanged. The daemon already produced a meaningful
            // error (e.g. 404 "tool not found", 400 "bad payload") and
            // wrapping it as 502 obscures the real cause.
            $payload = $response->json();
            if (is_array($payload)) {
                return response()->json($payload, $response->status());
            }

            return response()->json([
                'error' => "Bridge HTTP {$response->status()}: ".substr((string) $response->body(), 0, 500),
            ], $response->status());
        }

        if ($response->serverError()) {
            return response()->json([
                'error' => "Bridge HTTP {$response->status()}: ".substr((string) $response->body(), 0, 500),
            ], 502);
        }

        // Touch last_seen_at so the connection's health stays fresh.
        $bridge->update(['last_seen_at' => now()]);

        $payload = $response->json();
        if (! is_array($payload)) {
            return response()->json([
                'error' => 'Bridge returned non-JSON body for an HTTP-direct MCP call.',
            ], 502);
        }

        return response()->json($payload);
    }

    /**
     * Streaming variant of mcpCallViaHttp: ask the daemon for an SSE
     * response and forward the bytes verbatim to the FleetQ caller.
     *
     * Pre-stream failures (DNS / connect / 4xx / 5xx) are collapsed to
     * the same JSON error shape mcpCallViaHttp uses, so a caller that
     * sets stream=true gets either a fully-streamed text/event-stream
     * response (200) or a single non-streamed JSON error (4xx/5xx). No
     * mid-flight transport switch.
     *
     * Output buffering: the response sets `X-Accel-Buffering: no` so
     * nginx forwards bytes as soon as flush() is called (without the
     * default 64k buffer). PHP-FPM's own output buffering is disabled
     * inside the stream callback via @ob_end_clean() before the first
     * write — see comment there.
     */
    private function mcpCallViaHttpStreaming(
        BridgeConnection $bridge,
        string $serverName,
        string $method,
        array $params,
        string $requestId,
        int $timeout,
    ): Response {
        $headers = [
            'Accept' => 'text/event-stream',
        ];
        if (! empty($bridge->endpoint_secret)) {
            $headers['Authorization'] = 'Bearer '.$bridge->endpoint_secret;
        }

        $url = rtrim($bridge->endpoint_url, '/').'/mcp/'.$serverName;
        $body = [
            'request_id' => $requestId,
            'method' => $method,
            'params' => $params,
            'timeout' => $timeout,
        ];

        try {
            $response = Http::withOptions(['stream' => true])
                ->timeout($timeout + 5)
                ->withHeaders($headers)
                ->post($url, $body);
        } catch (\Throwable $e) {
            Log::error('BridgeController: HTTP-direct streaming MCP call failed', [
                'request_id' => $requestId,
                'bridge_id' => $bridge->id,
                'endpoint_url' => $bridge->endpoint_url,
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'error' => "Bridge HTTP-direct streaming call failed: {$e->getMessage()}",
            ], 502);
        }

        if ($response->clientError()) {
            // Same 4xx pass-through as the synchronous path: the daemon
            // already produced a meaningful client error and wrapping it
            // as 502 would obscure the cause. Body is at most 500 chars
            // here because we never started reading the stream body.
            $bodyTail = substr((string) $response->body(), 0, 500);
            $decoded = json_decode($bodyTail, true);
            if (is_array($decoded)) {
                return response()->json($decoded, $response->status());
            }

            return response()->json([
                'error' => "Bridge HTTP {$response->status()}: ".$bodyTail,
            ], $response->status());
        }

        if ($response->serverError()) {
            return response()->json([
                'error' => "Bridge HTTP {$response->status()}: ".substr((string) $response->body(), 0, 500),
            ], 502);
        }

        $bridge->update(['last_seen_at' => now()]);

        $psrStream = $response->toPsrResponse()->getBody();

        return new StreamedResponse(
            function () use ($psrStream): void {
                // Production-only output-buffer handling. In tests Laravel's
                // TestResponse wraps the streamedContent() invocation in its
                // own ob_start with an output handler that captures into a
                // string; dropping or flushing buffers from inside the
                // callback collides with that wrapper and surfaces as
                // "Failed to delete buffer. No buffer to delete" on the
                // framework's own teardown. We rely on `X-Accel-Buffering:
                // no` + Laravel's `cleanOutputBuffers` to do the right thing
                // outside tests.
                $inTests = app()->runningUnitTests();
                if (! $inTests) {
                    while (ob_get_level() > 0) {
                        if (ob_get_clean() === false) {
                            break;
                        }
                    }
                }

                while (! $psrStream->eof()) {
                    $chunk = $psrStream->read(8192);
                    if ($chunk === '') {
                        // PSR-7 streams can return '' transiently while the
                        // upstream is still producing — yield CPU briefly.
                        usleep(10_000);

                        continue;
                    }
                    echo $chunk;
                    if (! $inTests) {
                        flush();
                    }
                }
            },
            200,
            [
                'Content-Type' => 'text/event-stream',
                'Cache-Control' => 'no-cache',
                'Connection' => 'keep-alive',
                'X-Accel-Buffering' => 'no',
            ],
        );
    }

    /**
     * Connect a bridge via HTTP tunnel URL (HTTP mode).
     *
     * The customer pastes their tunnel URL (Cloudflare Tunnel, Tailscale Funnel, ngrok, etc.).
     * FleetQ calls /discover to validate and fetch the available agents/LLMs.
     *
     * @response 201 {"data": {"id": "...", "endpoint_url": "...", "agents": [...]}}
     */
    public function connect(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'endpoint_url' => 'required|url|max:500',
            'endpoint_secret' => 'nullable|string|max:255',
            'tunnel_provider' => 'nullable|string|in:cloudflare,tailscale,ngrok,custom|max:50',
            'label' => 'nullable|string|max:100',
        ]);

        $teamId = $this->resolveTeamId($request);
        $endpointUrl = rtrim($validated['endpoint_url'], '/');

        // Validate URL by calling /discover on the bridge server
        $discoverUrl = $endpointUrl.'/discover';
        $headers = [];

        if (! empty($validated['endpoint_secret'])) {
            $headers['Authorization'] = 'Bearer '.$validated['endpoint_secret'];
        }

        try {
            $discoverResponse = Http::timeout(10)
                ->withHeaders($headers)
                ->get($discoverUrl);

            if (! $discoverResponse->successful()) {
                return response()->json([
                    'error' => "Bridge server responded with HTTP {$discoverResponse->status()}. "
                        .'Ensure your local bridge server is running and the tunnel is active.',
                ], 422);
            }

            $discovered = $discoverResponse->json();
        } catch (\Throwable $e) {
            return response()->json([
                'error' => 'Could not reach the bridge server at the provided URL: '.$e->getMessage(),
            ], 422);
        }

        $endpoints = [
            'agents' => $discovered['agents'] ?? [],
            'llm_endpoints' => $discovered['llm_endpoints'] ?? [],
            'mcp_servers' => $discovered['mcp_servers'] ?? [],
        ];

        $connection = BridgeConnection::updateOrCreate(
            ['team_id' => $teamId, 'endpoint_url' => $endpointUrl],
            [
                'status' => BridgeConnectionStatus::Connected,
                'endpoint_secret' => $validated['endpoint_secret'] ?? null,
                'tunnel_provider' => $validated['tunnel_provider'] ?? 'custom',
                'label' => $validated['label'] ?? null,
                'endpoints' => $endpoints,
                'connected_at' => now(),
                'last_seen_at' => now(),
            ],
        );

        return response()->json(['data' => [
            'id' => $connection->id,
            'endpoint_url' => $connection->endpoint_url,
            'tunnel_provider' => $connection->tunnel_provider,
            'label' => $connection->label,
            'status' => $connection->status->value,
            'agents' => $connection->agents(),
            'llm_endpoints' => $connection->llmEndpoints(),
            'mcp_servers' => $connection->mcpServers(),
            'connected_at' => $connection->connected_at->toISOString(),
        ]], 201);
    }

    /**
     * Update the endpoint URL for an HTTP-mode bridge connection.
     *
     * Use when the tunnel URL changes (e.g., Cloudflare quick tunnels regenerate on restart).
     */
    public function updateUrl(Request $request, BridgeConnection $connection): JsonResponse
    {
        $teamId = $this->resolveTeamId($request);

        if ($connection->team_id !== $teamId) {
            return response()->json(['error' => 'Not found.'], 404);
        }

        $validated = $request->validate([
            'endpoint_url' => 'required|url|max:500',
            'endpoint_secret' => 'nullable|string|max:255',
        ]);

        $connection->update([
            'endpoint_url' => rtrim($validated['endpoint_url'], '/'),
            'endpoint_secret' => $validated['endpoint_secret'] ?? $connection->endpoint_secret,
            'last_seen_at' => now(),
        ]);

        return response()->json(['data' => [
            'id' => $connection->id,
            'endpoint_url' => $connection->endpoint_url,
            'updated' => true,
        ]]);
    }

    /**
     * Ping an HTTP-mode bridge connection to verify it is reachable.
     *
     * Calls GET /health on the bridge server and updates last_seen_at / status.
     */
    public function ping(Request $request, BridgeConnection $connection): JsonResponse
    {
        $teamId = $this->resolveTeamId($request);

        if ($connection->team_id !== $teamId) {
            return response()->json(['error' => 'Not found.'], 404);
        }

        if (! $connection->isHttpMode()) {
            return response()->json(['error' => 'Ping is only available for HTTP-mode connections.'], 422);
        }

        $headers = [];

        if (! empty($connection->endpoint_secret)) {
            $headers['Authorization'] = 'Bearer '.$connection->endpoint_secret;
        }

        try {
            $healthResponse = Http::timeout(10)
                ->withHeaders($headers)
                ->get(rtrim($connection->endpoint_url, '/').'/health');

            $online = $healthResponse->successful()
                && ($healthResponse->json('status') === 'ok' || $healthResponse->successful());

            $connection->update([
                'status' => $online ? BridgeConnectionStatus::Connected : BridgeConnectionStatus::Disconnected,
                'last_seen_at' => now(),
            ]);

            return response()->json(['data' => [
                'online' => $online,
                'status' => $connection->status->value,
                'last_seen_at' => $connection->last_seen_at->toISOString(),
            ]]);
        } catch (\Throwable $e) {
            $connection->update(['status' => BridgeConnectionStatus::Disconnected]);

            return response()->json(['data' => [
                'online' => false,
                'status' => BridgeConnectionStatus::Disconnected->value,
                'error' => $e->getMessage(),
            ]]);
        }
    }

    /**
     * Disconnect a bridge session.
     *
     * Accepts optional session_id (relay-generated, used to guard against reconnect races)
     * or connection_id to disconnect a specific bridge.
     * Without either, disconnects all active bridges for the team.
     */
    public function disconnect(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'session_id' => 'nullable|string|max:255',
            'connection_id' => 'nullable|uuid',
        ]);

        $teamId = $this->resolveTeamId($request);
        $sessionId = $validated['session_id'] ?? null;
        $connectionId = $validated['connection_id'] ?? null;

        // If session_id is provided (relay disconnect), only disconnect if it still
        // matches the active connection. Guards against the reconnect race where the
        // old relay goroutine fires DELETE after a new session has already registered.
        if ($sessionId) {
            $connection = BridgeConnection::where('team_id', $teamId)
                ->where('session_id', $sessionId)
                ->active()
                ->first();

            if (! $connection) {
                // Either already superseded by a newer session or already disconnected.
                Log::info('BridgeController: stale disconnect ignored', [
                    'team_id' => $teamId,
                    'session_id' => $sessionId,
                ]);

                return response()->json(['data' => ['disconnected' => 0, 'reason' => 'stale']]);
            }

            app(TerminateBridgeConnection::class)->execute($connection);

            return response()->json(['data' => ['disconnected' => 1]]);
        }

        $query = BridgeConnection::where('team_id', $teamId)->active();

        if ($connectionId) {
            $query->where('id', $connectionId);
        }

        $connections = $query->get();

        if ($connections->isEmpty()) {
            return response()->json(['error' => 'No active bridge connection.'], 404);
        }

        $connections->each(fn ($c) => app(TerminateBridgeConnection::class)->execute($c));

        return response()->json(['data' => ['disconnected' => $connections->count()]]);
    }
}
