<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Group;
use App\Models\Organisation;
use App\Models\OrganisationExternalIdentities;
use App\Models\StateTracker;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

class TransitProvisioningTest extends TestCase
{

    use RefreshDatabase;

    private $organisations = [];
    private $users = [];

    protected function setUp(): void
    {
        parent::setUp();

        $this->organisations = [
            'A' => Organisation::create([ 'id' => 1, 'name' => 'A' ]),
            'B' => Organisation::create([ 'id' => 2, 'name' => 'B' ])
        ];

        $group = Group::create([ 'name' => 'Test' ]);

        $default_user = [
            'phone'             => null,
            'password'          => '',
            'group_id'          => $group->id,
        ];

        $this->users = [
            'A' => User::create([ ...$default_user, 'organisation_id' => $this->organisations['A']->id, 'name' => 'A', 'email' => 'creatia+1@globalsecurelayer.com' ]),
            'B' => User::create([ ...$default_user, 'organisation_id' => $this->organisations['B']->id, 'name' => 'B', 'email' => 'creatia+2@globalsecurelayer.com' ])
        ];
    }

    // Cache a port hash -> uuid mapping the way get_port_list does.
    private function seed_port(User $_user, string $_seed, string $_uuid): string
    {
        $hash = hash('sha512', $_seed);

        Redis::setex('U:' . $_user->id . ':P:' . $hash, 600, $_uuid);

        return $hash;
    }

    // A stored job row in the current shape: identifier is {128-hex hash}-{user}-{context}.
    // Returns [ hash, identifier ] - the hash is all the client ever sees.
    private function stored_key(User $_user, string $_context, ?string $_hash = null): array
    {
        $hash = $_hash ?? hash('sha512', $_context . '|' . microtime(true) . '|' . random_int(0, PHP_INT_MAX));

        return [ $hash, $hash . '-' . $_user->id . '-' . $_context ];
    }

    // The response hands back only the opaque 128-hex hash; the backing row is
    // {hash}-{user}-{context}, with the context intact and no client-supplied prefix.
    private function assert_job_key(?string $_job, User $_user, string $_context): void
    {
        $this->assertNotNull($_job, 'no job key in the response');
        $this->assertMatchesRegularExpression('/^[a-f0-9]{128}$/', $_job);
        $this->assertDatabaseHas('state_trackers', [ 'identifier' => $_job . '-' . $_user->id . '-' . $_context ]);
    }

    private function running_job(): array
    {
        return [ 'job_id' => 'job-test-1', 'status' => 'running', 'message' => 'Provisioning in progress' ];
    }

    // A scan port in the current microservice shape: sanitised sid-only descriptions,
    // ipt flag, user_description slots, and bgp_sessions without ids.
    private function scan_port(string $_router, string $_interface, string $_type, string $_sid, string $_temp_id, array $_overrides = []): array
    {
        return [
            'key'               => "{$_router}_{$_interface}",
            'temp_id'           => $_temp_id,
            'router_name'       => $_router,
            'interface_name'    => $_interface,
            'interface_type'    => $_type,
            'sid'               => $_sid,
            'description'       => $_sid,
            'ipt'               => true,
            'parent_interface'  => null,
            'parent_po'         => null,
            'pc_members'        => [],
            'eth_eligible'      => true,
            'eth_reason'        => null,
            'subinterfaces'     => [],
            'bgp_sessions'      => [],
            'ethernet'          => [ 'sid' => $_sid, 'eligible' => true, 'reason' => null, 'slots' => [ 'used' => 0, 'cap' => 2 ], 'evcs' => [], 'tags' => [] ],
            'user_description'  => null,
            ...$_overrides
        ];
    }

    /**
     * Port listing
     */

    public function test_get_port_list_groups_lags_and_keeps_multiple_sessions_per_interface(): void
    {
        OrganisationExternalIdentities::create([
            'key'             => 'gsl',
            'organisation_id' => $this->organisations['A']->id,
            'identity'        => '1'
        ]);

        $session = fn (string $_interface, int $_asn, string $_v4, string $_v6, string $_filter) => [
            'sid'                 => 'GSL-30346',
            'interface_name'      => $_interface,
            'asn'                 => $_asn,
            'peer_ipv4'           => $_v4,
            'peer_ipv6'           => $_v6,
            'filter_type'         => $_filter,
            'deletion_requested'  => false,
            'bfd_enabled'         => false,
            'null_routing'        => false
        ];

        Http::fake([ '*/custview/cxid/*' => Http::response([
            'cxid'  => '1',
            'count' => 4,
            'ports' => [
                // One primary interface carrying several BGP sessions across its subinterfaces
                $this->scan_port('hk-eqxhk1-cr2', 'Ethernet2/1', 'primary', 'GSL-30346', 'uuid-hk-primary', [
                    'subinterfaces' => [
                        [ 'interface_name' => 'Ethernet2/1.1101', 'sid' => 'GSL-30346', 'vlan' => '1101', 'description' => 'GSL-30346', 'bgp_neighbor_ips' => [ '10.16.0.27', '2401:3cc0::4:595' ], 'user_description' => null ],
                        [ 'interface_name' => 'Ethernet2/1.1102', 'sid' => 'GSL-30346', 'vlan' => '1102', 'description' => 'GSL-30346', 'bgp_neighbor_ips' => [ '10.16.0.31', '2401:3cc0::4:597' ], 'user_description' => null ]
                    ],
                    'bgp_sessions' => [
                        $session('Ethernet2/1.1101', 4743, '10.16.0.27', '2401:3cc0::4:595', 'full'),
                        $session('Ethernet2/1.1102', 53616, '10.16.0.31', '2401:3cc0::4:597', 'full')
                    ]
                ]),
                $this->scan_port('mel-eqxme1-cr3', 'Ethernet17/1', 'lag-member', 'GSL-30219', 'uuid-mel-e17', [
                    'parent_interface' => 'Port-Channel30', 'parent_po' => 'Port-Channel30', 'ethernet' => null
                ]),
                $this->scan_port('mel-eqxme1-cr3', 'Ethernet18/1', 'lag-member', 'GSL-30219', 'uuid-mel-e18', [
                    'parent_interface' => 'Port-Channel30', 'parent_po' => 'Port-Channel30', 'ethernet' => null
                ]),
                $this->scan_port('mel-eqxme1-cr3', 'Port-Channel30', 'port-channel', 'GSL-30219', 'uuid-mel-po30', [
                    'pc_members' => [ 'Ethernet17/1', 'Ethernet18/1' ]
                ])
            ]
        ], 200) ]);

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/port?draw=7&start=0&length=25');

        $response->assertStatus(200)->assertJson([
            'draw'            => 7,
            'recordsTotal'    => 2,
            'recordsFiltered' => 2
        ]);

        $rows = collect($response->json('data'))->keyBy('interface_name');

        // The multi-session primary keeps every session and the new shape fields
        $primary = $rows['Ethernet2/1'];

        $this->assertCount(2, $primary['bgp_sessions']);
        $this->assertEquals([ 4743, 53616 ], array_column($primary['bgp_sessions'], 'asn'));
        $this->assertArrayNotHasKey('id', $primary['bgp_sessions'][0]);
        $this->assertTrue($primary['ipt']);
        $this->assertNull($primary['user_description']);
        $this->assertEquals('GSL-30346', $primary['description']);
        $this->assertNull($primary['subinterfaces'][0]['user_description']);
        $this->assertEquals([], $primary['lag_members']);

        // LAG members fold under their port-channel instead of appearing as rows
        $po = $rows['Port-Channel30'];

        $this->assertCount(2, $po['lag_members']);
        $this->assertEquals([ 'Ethernet17/1', 'Ethernet18/1' ], array_column($po['lag_members'], 'interface_name'));

        // Every row and folded member carries an id hash instead of its temp_id, cached for later lookups
        foreach([ [ $primary, 'uuid-hk-primary' ], [ $po, 'uuid-mel-po30' ], [ $po['lag_members'][0], 'uuid-mel-e17' ], [ $po['lag_members'][1], 'uuid-mel-e18' ] ] as [ $row, $uuid ]) {
            $this->assertArrayNotHasKey('temp_id', $row);
            $this->assertMatchesRegularExpression('/^[a-f0-9]{128}$/', $row['id']);
            $this->assertEquals($uuid, Redis::get('U:' . $this->users['A']->id . ':P:' . $row['id']));
        }
    }

    /**
     * Session creation
     */

    public function test_add_bgp_session_actually_provisions_and_tracks_the_job(): void
    {
        // The microservice reports "Provisioning in progress" with a job id.
        Http::fake([ '*/custview/*/bgp' => Http::response([
            'job_id'  => 'job-provision-1',
            'status'  => 'running',
            'message' => 'Provisioning in progress'
        ], 200) ]);

        $asn = Asns::create([ 'organisation_id' => $this->organisations['A']->id, 'asn' => 64500, 'approved' => 1 ]);

        $hash = $this->seed_port($this->users['A'], 'create-port', 'uuid-create');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post('/api/transit/session', [
            'identity' => $hash,
            'interface'=> 'Ethernet41/1',
            'v4_hash'  => 'v4hash',
            'v6_hash'  => 'v6hash',
            'asn_id'   => $asn->id,
            'filter'   => 'full',
            'bfd'      => true,
            'v4_max'   => 100,
            'v6_max'   => 200
        ]);

        $response->assertStatus(200)->assertJson([ 'circuit' => $hash ]);
        $this->assert_job_key($response->json('job'), $this->users['A'], 'session-create:Ethernet41/1');

        $this->assertDatabaseHas('state_trackers', [
            'identifier' => $response->json('job') . '-' . $this->users['A']->id . '-session-create:Ethernet41/1',
            'detail'     => 'job-provision-1'
        ]);

        // The provisioning call is genuinely made against the resolved UUID -
        // regression guard: this call was once silently dropped.
        // Value-equality (==) on numerics/bool: asn is an integer column that can
        // round-trip as an int or numeric string depending on the DB driver, and
        // form-posted values arrive stringified - the values match, the PHP types
        // are what's ambiguous. Strings stay strict.
        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-create/bgp')
                && $request['interface_name'] === 'Ethernet41/1'
                && $request['asn'] == 64500
                && $request['filter'] === 'full'
                && $request['ipv4_hash'] === 'v4hash'
                && $request['ipv6_hash'] === 'v6hash'
                && $request['max_prefix_4'] == 100
                && $request['max_prefix_6'] == 200
                && (bool) $request['bfd'] === true
                && (string) $request['org_id'] === '1'
                && $request['username'] === 'A';
        });

        // The scan identity is destroyed once provisioning is in flight
        $this->assertEquals(0, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));
    }

    public function test_add_bgp_session_rejects_an_unknown_identity(): void
    {
        Http::fake();

        $asn = Asns::create([ 'organisation_id' => $this->organisations['A']->id, 'asn' => 64500, 'approved' => 1 ]);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post('/api/transit/session', [
            'identity' => hash('sha512', 'never-cached'),
            'interface'=> 'Ethernet41/1',
            'v4_hash'  => 'v4hash',
            'v6_hash'  => 'v6hash',
            'asn_id'   => $asn->id,
            'filter'   => 'full',
            'bfd'      => true
        ])->assertStatus(400);

        Http::assertNothingSent();
    }

    /**
     * Session lifecycle
     */

    public function test_update_policy_submits_a_session_config_job(): void
    {
        Http::fake([ '*/custview/*/session-config' => Http::response($this->running_job(), 200) ]);

        $hash = $this->seed_port($this->users['A'], 'policy-port', 'uuid-policy');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'full-nodefault'
        ]);

        $response->assertStatus(200)->assertJson([ 'circuit' => $hash ]);

        $this->assert_job_key($response->json('job'), $this->users['A'], 'policy:Ethernet1/1');

        $this->assertDatabaseHas('state_trackers', [
            'identifier' => $response->json('job') . '-' . $this->users['A']->id . '-policy:Ethernet1/1',
            'detail'     => 'job-test-1'
        ]);

        // The cached identity is destroyed once the change is in flight - the front-end must re-scan
        $this->assertEquals(0, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-policy/session-config')
                && $request['interface_name'] === 'Ethernet1/1'
                && $request['filter'] === 'full-nodefault'
                && $request['org_id'] === '1'
                && $request['username'] === 'A';
        });
    }

    public function test_update_policy_rejects_an_unknown_filter(): void
    {
        Http::fake();

        $hash = $this->seed_port($this->users['A'], 'policy-bad-filter', 'uuid-bad-filter');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'everything'
        ])->assertStatus(400);

        Http::assertNothingSent();
    }

    public function test_update_policy_rejects_an_unknown_identity(): void
    {
        Http::fake();

        $hash = hash('sha512', 'never-cached');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'full'
        ])->assertStatus(400);

        Http::assertNothingSent();
    }

    public function test_refresh_limit_submits_a_max_prefix_refresh(): void
    {
        Http::fake([ '*/custview/*/session-config' => Http::response($this->running_job(), 200) ]);

        $hash = $this->seed_port($this->users['A'], 'limit-port', 'uuid-limit');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/limit", [
            'interface' => 'Ethernet1/1.20'
        ]);

        $response->assertStatus(200)->assertJsonStructure([ 'circuit', 'job' ]);

        // The context survives an interface name that contains dots and dashes
        $this->assert_job_key($response->json('job'), $this->users['A'], 'max-prefix:Ethernet1/1.20');

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-limit/session-config')
                && $request['interface_name'] === 'Ethernet1/1.20'
                && $request['refresh_max_prefix'] === true;
        });
    }

    public function test_delete_session_submits_a_deletion_job(): void
    {
        Http::fake([ '*/custview/*/bgp/delete' => Http::response($this->running_job(), 200) ]);

        $hash = $this->seed_port($this->users['A'], 'delete-port', 'uuid-delete');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/delete", [
            'interface' => 'Port-Channel30'
        ]);

        $response->assertStatus(200);

        $this->assert_job_key($response->json('job'), $this->users['A'], 'session-delete:Port-Channel30');

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-delete/bgp/delete')
                && $request['interface_name'] === 'Port-Channel30'
                && $request['org_id'] === '1'
                && $request['username'] === 'A';
        });
    }

    public function test_bfd_null_and_password_ride_the_session_config_queue(): void
    {
        Http::fake([ '*/custview/*/session-config' => Http::response($this->running_job(), 200) ]);

        // Each success destroys the cached identity, so every call gets a fresh one
        $hash = $this->seed_port($this->users['A'], 'button-port-bfd', 'uuid-buttons');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/bfd", [
            'interface' => 'Ethernet1/1',
            'enable'    => true
        ]);

        $response->assertStatus(200);
        $this->assert_job_key($response->json('job'), $this->users['A'], 'bfd:Ethernet1/1');

        // assertSent runs the closure against EVERY sent request, so guard keys
        // that only exist on this one - all three ride /session-config.
        Http::assertSent(fn (ClientRequest $request) => ($request['bfd'] ?? null) === true && $request['interface_name'] === 'Ethernet1/1');

        $this->assertEquals(0, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));

        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);
        $hash = $this->seed_port($this->users['A'], 'button-port-null', 'uuid-buttons');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/null", [
            'interface' => 'Ethernet1/1',
            'enable'    => true
        ])->assertStatus(200)->assertJsonStructure([ 'circuit', 'job' ]);

        Http::assertSent(fn (ClientRequest $request) => ($request['nulling'] ?? null) === true);

        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);
        $hash = $this->seed_port($this->users['A'], 'button-port-password', 'uuid-buttons');

        // A null password is still a change - it removes the current one
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/password", [
            'interface' => 'Ethernet1/1',
            'password'  => null
        ])->assertStatus(200)->assertJsonStructure([ 'circuit', 'job' ]);

        // A cleared password sends an explicit null - distinguish that from a
        // missing key (which the null-coalesce guards above would conflate).
        Http::assertSent(function (ClientRequest $request) {
            $data = $request->data();
            return ($data['change_password'] ?? null) === true
                && array_key_exists('password', $data)
                && $data['password'] === null;
        });
    }

    /**
     * Per-job status
     */

    public function test_get_job_is_scoped_to_the_caller(): void
    {
        Http::fake();

        [ $hash, $identifier ] = $this->stored_key($this->users['A'], 'policy:Ethernet1/1');

        StateTracker::create([ 'identifier' => $identifier, 'detail' => 'job-test-1' ]);

        // Another caller cannot poll it - the -{user}- binding is re-derived from Auth,
        // so A's hash resolves to no row of B's.
        $this->actingAs($this->users['B'])->withoutMiddleware()->get("/api/transit/job/$hash")
            ->assertStatus(400);

        // A well-formed but unknown hash is refused too
        $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job/' . hash('sha512', 'never-issued'))
            ->assertStatus(400);

        // A key that is not an opaque 128-hex hash is rejected: the controller's format
        // guard answers 400, and where the route constraint is enabled it 404s first -
        // either way the old slash-spanning parameter is closed and no work is done.
        $this->assertContains(
            $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job/PROVISION-1-1-session-delete:Ethernet2/1.1420-abcd1234')->status(),
            [400, 404]
        );

        Http::assertNothingSent();
    }

    public function test_get_job_reports_generic_states_only(): void
    {
        Http::fake([
            '*/custview/job/job-running*'  => Http::response([ 'job_id' => 'job-running',  'status' => 'running',  'message' => 'internal', 'detail' => 'secret' ], 200),
            '*/custview/job/job-complete*' => Http::response([ 'job_id' => 'job-complete', 'status' => 'complete', 'message' => 'internal', 'detail' => 'secret' ], 200),
            '*/custview/job/job-failed*'   => Http::response([ 'job_id' => 'job-failed',   'status' => 'failed',   'message' => 'internal', 'detail' => 'secret' ], 200)
        ]);

        $hashes = [];
        $identifiers = [];

        foreach([ 'running', 'complete', 'failed' ] as $state) {
            [ $hashes[$state], $identifiers[$state] ] = $this->stored_key($this->users['A'], "poll:$state");
            StateTracker::create([ 'identifier' => $identifiers[$state], 'detail' => "job-$state" ]);
        }

        // The poll carries the context and hash back so the front-end can render the
        // toast, but the technical detail/message from the microservice never leaks.
        $running = $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job/' . $hashes['running']);
        $running->assertStatus(200)->assertExactJson([ 'status' => 'running', 'message' => 'Provisioning in progress', 'context' => 'poll:running', 'job' => $hashes['running'] ]);

        $complete = $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job/' . $hashes['complete']);
        $complete->assertStatus(200)->assertExactJson([ 'status' => 'complete', 'message' => 'Provisioning complete', 'context' => 'poll:complete', 'job' => $hashes['complete'] ]);

        $failed = $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job/' . $hashes['failed']);
        $failed->assertStatus(200)->assertExactJson([ 'status' => 'failure', 'message' => 'Provisioning failed - our team has been notified', 'context' => 'poll:failed', 'job' => $hashes['failed'] ]);

        // Terminal states are persisted so later polls skip the microservice
        $this->assertDatabaseHas('state_trackers', [ 'identifier' => $identifiers['complete'], 'detail' => 'complete' ]);
        $this->assertDatabaseHas('state_trackers', [ 'identifier' => $identifiers['failed'], 'detail' => 'failure' ]);

        // The technical detail never reaches the response - Slack reporting is
        // the microservice's job, not the wrapper's.
    }

    /**
     * Port configuration
     */

    public function test_port_config_passes_the_context_through(): void
    {
        $context = [
            'cxid'       => '1',
            'lags'       => [ [ 'name' => 'Port-Channel30', 'shape_rate' => null, 'members' => [ [ 'name' => 'Ethernet17/1', 'description' => 'LAG-CUST' ] ] ] ],
            'candidates' => [],
            'creatable'  => []
        ];

        Http::fake([ '*/custview/*/portconfig' => Http::response($context, 200) ]);

        $hash = $this->seed_port($this->users['A'], 'config-port', 'uuid-config');

        $this->actingAs($this->users['A'])->withoutMiddleware()->get("/api/transit/port/$hash/config")
            ->assertStatus(200)
            ->assertExactJson($context);
    }

    public function test_the_last_lag_member_cannot_be_removed(): void
    {
        Http::fake([
            '*/custview/uuid-single/portconfig' => Http::response([
                'cxid' => '1', 'candidates' => [], 'creatable' => [],
                'lags' => [ [ 'name' => 'Port-Channel30', 'shape_rate' => null, 'members' => [ [ 'name' => 'Ethernet17/1', 'description' => '-' ] ] ] ]
            ], 200),
            '*/custview/uuid-double/portconfig' => Http::response([
                'cxid' => '1', 'candidates' => [], 'creatable' => [],
                'lags' => [ [ 'name' => 'Port-Channel30', 'shape_rate' => null, 'members' => [ [ 'name' => 'Ethernet17/1', 'description' => '-' ], [ 'name' => 'Ethernet18/1', 'description' => '-' ] ] ] ]
            ], 200),
            '*/custview/*/portconfig/lag/member' => Http::response([ 'success' => true ], 200)
        ]);

        $single = $this->seed_port($this->users['A'], 'single-member', 'uuid-single');
        $double = $this->seed_port($this->users['A'], 'double-member', 'uuid-double');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$single/lag/member", [
            'port_channel' => 'Port-Channel30',
            'interface'    => 'Ethernet17/1',
            'action'       => 'remove'
        ])->assertStatus(400);

        Http::assertNotSent(fn (ClientRequest $request) => str_contains($request->url(), 'portconfig/lag/member'));

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$double/lag/member", [
            'port_channel' => 'Port-Channel30',
            'interface'    => 'Ethernet18/1',
            'action'       => 'remove'
        ])->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-double/portconfig/lag/member')
                && $request['port_channel'] === 'Port-Channel30'
                && $request['interface'] === 'Ethernet18/1'
                && $request['action'] === 'remove';
        });
    }

    public function test_a_lag_member_can_be_added(): void
    {
        Http::fake([ '*/custview/*/portconfig/lag/member' => Http::response([ 'success' => true ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'add-member', 'uuid-add');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lag/member", [
            'port_channel' => 'Port-Channel30',
            'interface'    => 'Ethernet19/1',
            'action'       => 'add'
        ])->assertStatus(200);
    }

    public function test_a_lag_can_be_created(): void
    {
        Http::fake([ '*/custview/*/portconfig/lag/create' => Http::response([ 'success' => true, 'port_channel' => 'Port-Channel7' ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'lag-create', 'uuid-lag-create');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lag/create", [
            'interface' => 'Ethernet49/1'
        ])->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-lag-create/portconfig/lag/create')
                && $request['interface'] === 'Ethernet49/1';
        });
    }

    public function test_lp_limit_validates_and_clears(): void
    {
        Http::fake([ '*/custview/*/lplimit' => Http::response([ 'success' => true, 'interface_name' => 'Ethernet49/1' ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'lp-port', 'uuid-lp');

        // shape_rate must be present, in range or null
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lplimit", [])
            ->assertStatus(400);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lplimit", [ 'shape_rate' => 9 ])
            ->assertStatus(400);

        // null clears the shape
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lplimit", [ 'shape_rate' => null ])
            ->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-lp/lplimit')
                && $request['shape_rate'] === null;
        });

        // Port configuration is not a session change - the cached identity survives
        $this->assertEquals(1, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));

        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/lplimit", [ 'shape_rate' => 35 ])
            ->assertStatus(200);

        Http::assertSent(fn (ClientRequest $request) => $request['shape_rate'] === 35);
    }

    public function test_user_description_sets_clears_and_is_not_rate_limited(): void
    {
        // Regression guard: this endpoint does NOT answer { success: true } like
        // lplimit/lag - it echoes the saved label + netbox targets, and the 2xx IS
        // the success. A genuine save must return 200, not 500.
        Http::fake([
            '*/custview/*/userdescription' => Http::response([
                'sid'              => 'GSL-30514',
                'user_description' => 'Primary uplink',
                'netbox'           => [ 'targets' => [ [ 'router' => 'lon-thw-cr7', 'interface' => 'Ethernet1/3', 'ok' => true ] ] ],
                'warnings'         => [],
                'message'          => 'Description saved for GSL-30514.'
            ], 200),
            '*/custview/*/session-config'  => Http::response($this->running_job(), 200)
        ]);

        $hash = $this->seed_port($this->users['A'], 'desc-port', 'uuid-desc');

        // A label is set through the `user_description` field the microservice expects
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => 'Primary uplink'
        ])->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-desc/userdescription')
                && $request['user_description'] === 'Primary uplink'
                && $request['username'] === 'A';
        });

        // It is a portal-only label, not a router change - the cached identity survives
        $this->assertEquals(1, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));

        // An empty string clears the label - the wire value is an explicit null
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => ''
        ])->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            $data = $request->data();
            return str_contains($request->url(), 'userdescription')
                && array_key_exists('user_description', $data)
                && $data['user_description'] === null;
        });

        // The label never touches the shared change limiter: a session change and any
        // number of label edits coexist inside the same minute.
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'full'
        ])->assertStatus(200);

        $this->seed_port($this->users['A'], 'desc-port', 'uuid-desc');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => 'Secondary uplink'
        ])->assertStatus(200);
    }

    public function test_user_description_surfaces_a_microservice_failure_as_500(): void
    {
        // A non-2xx from the microservice must NOT read as a save - success is the
        // HTTP status, so a failure stays a failure (the other side of the regression).
        Http::fake([ '*/custview/*/userdescription' => Http::response([ 'detail' => 'boom' ], 500) ]);

        $hash = $this->seed_port($this->users['A'], 'desc-fail', 'uuid-desc-fail');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => 'Primary uplink'
        ])->assertStatus(500);
    }

    public function test_user_description_validates_the_label(): void
    {
        Http::fake([ '*/custview/*/userdescription' => Http::response([ 'success' => true ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'desc-validate', 'uuid-desc-validate');

        // The field must be present at all
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [])
            ->assertStatus(400);

        // Only alphanumerics, spaces and dashes are allowed
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => 'no/slashes:here'
        ])->assertStatus(400);

        // 100 characters is the ceiling
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/userdescription", [
            'user_description' => str_repeat('a', 101)
        ])->assertStatus(400);

        Http::assertNothingSent();
    }

    public function test_all_change_requests_share_one_per_minute_limiter(): void
    {
        Http::fake([
            '*/custview/*/session-config' => Http::response($this->running_job(), 200),
            '*/custview/*/lplimit'        => Http::response([ 'success' => true, 'interface_name' => 'Ethernet49/1' ], 200),
            '*/custview/*/target-id'      => Http::response([ 'code' => 'TGT-AB12CD', 'vlan' => 601 ], 200),
            '*/custview/*/evc'            => Http::response($this->running_job(), 200)
        ]);

        $session = $this->seed_port($this->users['A'], 'shared-session', 'uuid-shared-session');
        $port    = $this->seed_port($this->users['A'], 'shared-port', 'uuid-shared-port');
        $eth     = $this->seed_port($this->users['A'], 'shared-eth', 'uuid-shared-eth');

        // The first change of ANY kind goes through
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$session/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'full'
        ])->assertStatus(200);

        // Every other change type is refused inside the same minute - one bucket for all of them
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$port/lplimit", [ 'shape_rate' => 20 ])
            ->assertStatus(429);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$port/tag", [
            'vlan'      => 601,
            'interface' => 'Ethernet19/1'
        ])->assertStatus(429);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$eth/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601,
            'target_code' => 'TGT-AB12CD'
        ])->assertStatus(429);

        // The limiter is per user - another user's first change goes through
        $foreign = $this->seed_port($this->users['B'], 'shared-foreign', 'uuid-shared-foreign');

        $this->actingAs($this->users['B'])->withoutMiddleware()->post("/api/transit/bgp/$foreign/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'default'
        ])->assertStatus(200);

        // And once the bucket clears, the refused change goes through
        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$port/lplimit", [ 'shape_rate' => 20 ])
            ->assertStatus(200);
    }

    public function test_tag_creation_shares_the_change_limiter_but_keeps_the_identity(): void
    {
        Http::fake([ '*/custview/*/target-id' => Http::response([ 'code' => 'TGT-AB12CD', 'vlan' => 601 ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'tag-limited', 'uuid-tag-limited');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/tag", [
            'vlan'      => 601,
            'interface' => 'Ethernet19/1'
        ])->assertStatus(200);

        // Minting is a change request like any other - the second inside the minute is refused
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/tag", [
            'vlan'      => 602,
            'interface' => 'Ethernet19/1'
        ])->assertStatus(429);

        // Tag creation is synchronous, not a scan-invalidating job - the cached identity survives
        $this->assertEquals(1, Redis::exists('U:' . $this->users['A']->id . ':P:' . $hash));
    }

    /**
     * Ethernet / MEF
     */

    public function test_create_tag_returns_the_code_and_vlan(): void
    {
        Http::fake([ '*/custview/*/target-id' => Http::response([ 'code' => 'TGT-AB12CD', 'vlan' => 601 ], 200) ]);

        $hash = $this->seed_port($this->users['A'], 'tag-port', 'uuid-tag');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/tag", [
            'vlan'      => 601,
            'interface' => 'Ethernet19/1'
        ])->assertStatus(200)->assertExactJson([ 'code' => 'TGT-AB12CD', 'vlan' => 601 ]);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-tag/target-id')
                && $request['vlan'] === 601
                && $request['interface_name'] === 'Ethernet19/1';
        });
    }

    public function test_get_tag_whitelists_the_lookup(): void
    {
        // The microservice includes the tag OWNER's sid/device/interface -
        // none of that may reach the counterparty doing the lookup.
        $target = [ 'code' => 'TGT-AB12CD', 'status' => 'active', 'vlan' => 601, 'sid' => 'ORG-B-50001', 'device' => 'sg-eqxsg3-cr7', 'interface' => 'Ethernet19/1' ];

        Http::fake([ '*/custview/*/target-id/*' => Http::response($target, 200) ]);

        $hash = $this->seed_port($this->users['A'], 'tag-lookup-port', 'uuid-tag-lookup');

        $this->actingAs($this->users['A'])->withoutMiddleware()->get("/api/transit/port/$hash/tag/TGT-AB12CD")
            ->assertStatus(200)
            ->assertExactJson([ 'status' => 'active', 'vlan' => 601 ]);

        // Codes are alpha-dash only
        $this->actingAs($this->users['A'])->withoutMiddleware()->get("/api/transit/port/$hash/tag/TGT!123")
            ->assertStatus(400);
    }

    public function test_create_evc_resolves_the_z_end_hash(): void
    {
        Http::fake([ '*/custview/*/evc' => Http::response($this->running_job(), 200) ]);

        $a_hash = $this->seed_port($this->users['A'], 'evc-a-port', 'uuid-evc-a');
        $z_hash = $this->seed_port($this->users['A'], 'evc-z-port', 'uuid-evc-z');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$a_hash/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601,
            'z_id'        => $z_hash,
            'z_interface' => 'Ethernet5/1',
            'z_vlan'      => 601
        ]);

        $response->assertStatus(200);
        $this->assert_job_key($response->json('job'), $this->users['A'], 'evc-create:Ethernet19/1');

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-evc-a/evc')
                && $request['a_interface_name'] === 'Ethernet19/1'
                && $request['a_vlan'] === 601
                && $request['z_temp_id'] === 'uuid-evc-z'
                && $request['z_interface_name'] === 'Ethernet5/1'
                && $request['z_vlan'] === 601;
        });

        // Both ends' cached identities are destroyed - the circuit changed both ports
        $this->assertEquals(0, Redis::exists('U:' . $this->users['A']->id . ':P:' . $a_hash));
        $this->assertEquals(0, Redis::exists('U:' . $this->users['A']->id . ':P:' . $z_hash));
    }

    public function test_create_evc_accepts_a_target_code_instead(): void
    {
        Http::fake([ '*/custview/*/evc' => Http::response($this->running_job(), 200) ]);

        $a_hash = $this->seed_port($this->users['A'], 'evc-target-port', 'uuid-evc-target');

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$a_hash/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601,
            'target_code' => 'TGT-AB12CD'
        ])->assertStatus(200);

        Http::assertSent(function (ClientRequest $request) {
            return str_contains($request->url(), '/custview/uuid-evc-target/evc')
                && $request['target_code'] === 'TGT-AB12CD'
                && !isset($request['z_temp_id']);
        });
    }

    public function test_create_evc_needs_exactly_one_z_end(): void
    {
        Http::fake();

        $a_hash = $this->seed_port($this->users['A'], 'evc-ends-port', 'uuid-evc-ends');
        $z_hash = $this->seed_port($this->users['A'], 'evc-ends-z', 'uuid-evc-ends-z');

        // Neither end
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$a_hash/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601
        ])->assertStatus(400);

        // Both ends
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$a_hash/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601,
            'z_id'        => $z_hash,
            'z_interface' => 'Ethernet5/1',
            'z_vlan'      => 601,
            'target_code' => 'TGT-AB12CD'
        ])->assertStatus(400);

        // A Z hash that was never cached
        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$a_hash/evc", [
            'a_interface' => 'Ethernet19/1',
            'a_vlan'      => 601,
            'z_id'        => hash('sha512', 'never-cached-z'),
            'z_interface' => 'Ethernet5/1',
            'z_vlan'      => 601
        ])->assertStatus(400);

        Http::assertNothingSent();
    }

    public function test_delete_evc_submits_a_deletion_job(): void
    {
        Http::fake([ '*/custview/*/evc/*/delete' => Http::response($this->running_job(), 200) ]);

        $hash = $this->seed_port($this->users['A'], 'evc-delete-port', 'uuid-evc-delete');

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/evc/4026/delete", []);

        $response->assertStatus(200);
        $this->assert_job_key($response->json('job'), $this->users['A'], 'evc-delete:4026');

        Http::assertSent(fn (ClientRequest $request) => str_contains($request->url(), '/custview/uuid-evc-delete/evc/4026/delete'));

        // EVC ids are numeric
        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);

        $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/port/$hash/evc/not-a-number/delete", [])
            ->assertStatus(400);
    }

    /**
     * Job keys + the aggregate sweep
     */

    public function test_job_keys_are_unique_per_submission(): void
    {
        Http::fake([ '*/custview/*/session-config' => Http::response($this->running_job(), 200) ]);

        // Same port, same action, twice - the nonce keeps the keys (and their
        // pollers) from colliding.
        $hash = $this->seed_port($this->users['A'], 'nonce-port', 'uuid-nonce');

        $first = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'full'
        ])->json('job');

        RateLimiter::clear('bgp_change_rate_limit' . $this->users['A']->id);
        $this->seed_port($this->users['A'], 'nonce-port', 'uuid-nonce');

        $second = $this->actingAs($this->users['A'])->withoutMiddleware()->post("/api/transit/bgp/$hash/policy", [
            'interface' => 'Ethernet1/1',
            'filter'    => 'default'
        ])->json('job');

        $this->assertNotEquals($first, $second);
        $this->assert_job_key($first, $this->users['A'], 'policy:Ethernet1/1');
        $this->assert_job_key($second, $this->users['A'], 'policy:Ethernet1/1');
    }

    public function test_the_job_sweep_reports_status_context_and_key(): void
    {
        Http::fake([
            '*/custview/job/job-sweep-done' => Http::response([ 'job_id' => 'job-sweep-done', 'status' => 'complete', 'message' => 'internal' ], 200),
            '*/custview/job/job-sweep-bad'  => Http::response([ 'job_id' => 'job-sweep-bad',  'status' => 'failed',   'message' => 'internal' ], 200),
            '*/custview/job/job-sweep-run'  => Http::response([ 'job_id' => 'job-sweep-run',  'status' => 'running',  'message' => 'internal' ], 200),
            '*/custview/job/job-sweep-lost' => Http::response('not-json', 200)
        ]);

        [ $doneHash, $doneId ] = $this->stored_key($this->users['A'], 'policy:Port-Channel30');
        [ $badHash,  $badId ]  = $this->stored_key($this->users['A'], 'evc-delete:4026');
        [ , $runId ]           = $this->stored_key($this->users['A'], 'bfd:Ethernet1/1');
        [ , $foreignId ]       = $this->stored_key($this->users['B'], 'bfd:Ethernet1/1');
        [ , $lostId ]          = $this->stored_key($this->users['A'], 'password:Ethernet1/1');

        StateTracker::create([ 'identifier' => $doneId,    'detail' => 'job-sweep-done' ]);
        StateTracker::create([ 'identifier' => $badId,     'detail' => 'job-sweep-bad' ]);
        StateTracker::create([ 'identifier' => $runId,     'detail' => 'job-sweep-run' ]);
        // Another caller's job never appears in this caller's sweep
        StateTracker::create([ 'identifier' => $foreignId, 'detail' => 'job-sweep-done' ]);
        // A job the microservice cannot answer for is skipped, never leaked as a status
        StateTracker::create([ 'identifier' => $lostId,    'detail' => 'job-sweep-lost' ]);

        $response = $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job');

        $response->assertStatus(200);

        $jobs = collect($response->json());

        // Running + unanswerable rows omitted; the other user's row out of scope
        $this->assertCount(2, $jobs);

        // The client sees only the opaque hash, never the stored {hash}-{user}-{context}
        $done = $jobs->firstWhere('job', $doneHash);
        $this->assertEquals('complete', $done['status']);
        // The context is the whole tail after {hash}-{user}- so dashes inside it survive
        $this->assertEquals('policy:Port-Channel30', $done['context']);

        $bad = $jobs->firstWhere('job', $badHash);
        $this->assertEquals('failure', $bad['status']);
        $this->assertEquals('evc-delete:4026', $bad['context']);

        // Finished rows were flipped on that read - the sweep is one-shot per job
        $this->actingAs($this->users['A'])->withoutMiddleware()->get('/api/transit/job')
            ->assertStatus(200)
            ->assertExactJson([]);
    }
}
