<?php

namespace Tests\Feature;

use App\Helpers\Utility\SymEncrypt;
use App\Models\Organisation;
use App\Models\Webhook;
use App\Models\WebhookHookType;
use App\Models\WebhookMessage;
use App\Models\WebhookMessageStatus;
use App\Models\WebhookUsageType;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

/**
 * The mitigation webhook sender. Messages arrive as structured payloads; this command's whole job is
 * rendering them per platform - words for the chat hooks, the data itself for Raw hooks - while never
 * touching anything belonging to the legacy `reporting` pipeline.
 *
 * Organisation #1 holds three hooks: a Slack and a Raw hook on the mitigation usage, and a Slack hook
 * still on the legacy Reporting usage that must never receive a mitigation message.
 */
class HookProcessTest extends TestCase
{
    use RefreshDatabase;

    private array $hooks = [];

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

        config([
            'creatia.reporting.encryption_key' => config('creatia.reporting.encryption_key') ?? 'testing-webhook-key-0123456789ab',
            // Production paces deliveries to respect chat platform rate limits; the suite has no such
            // audience and no such patience.
            'creatia.hook.pace' => 0,
        ]);

        Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]);

        $sym_encrypt = new SymEncrypt(config('creatia.reporting.encryption_key'));

        $this->hooks = [
            'slack' => Webhook::create([
                'name'              => 'Mitigation Slack',
                'hook'              => $sym_encrypt->encrypt('http://localhost:56000/hook/slack'),
                'hook_type'         => WebhookHookType::Slack,
                'usage_type'        => WebhookUsageType::Mitigation,
                'organisation_id'   => 1
            ]),
            'raw' => Webhook::create([
                'name'              => 'Mitigation Raw',
                'hook'              => $sym_encrypt->encrypt('http://localhost:56000/hook/raw'),
                'hook_type'         => WebhookHookType::Raw,
                'usage_type'        => WebhookUsageType::Mitigation,
                'organisation_id'   => 1
            ]),
            'discord' => Webhook::create([
                'name'              => 'Mitigation Discord',
                'hook'              => $sym_encrypt->encrypt('http://localhost:56000/hook/discord'),
                'hook_type'         => WebhookHookType::Discord,
                'usage_type'        => WebhookUsageType::Mitigation,
                'organisation_id'   => 1
            ]),
            'legacy' => Webhook::create([
                'name'              => 'Legacy Reporting Slack',
                'hook'              => $sym_encrypt->encrypt('http://localhost:56000/hook/legacy'),
                'hook_type'         => WebhookHookType::Slack,
                'usage_type'        => WebhookUsageType::Reporting,
                'organisation_id'   => 1
            ]),
        ];
    }

    /**
     * A queued mitigation message on the given hook, with the standard attack-start payload unless
     * the caller shapes it otherwise.
     */
    private function queue(Webhook $hook, array $payload = [], ?Carbon $created = null): WebhookMessage
    {
        $message = new WebhookMessage([
            'key'               => 'test-' . uniqid(),
            'message_type'      => WebhookUsageType::Mitigation->value,
            'payload'           => $payload === [] ? $this->payload() : $payload,
            'webhook_id'        => $hook->id,
            'status'            => WebhookMessageStatus::Pending->value,
            'sent_at'           => null,
            'organisation_id'   => 1
        ]);

        $message->save();

        if($created !== null) {
            $message->timestamps = false;
            $message->forceFill([ 'created_at' => $created ])->save();
        }

        return $message;
    }

    private function payload(string $kind = 'attack-start'): array
    {
        /**
         * A fixed start time, so two calls produce byte-identical payloads - the dedupe check
         * compares serialised payload text and must see the duplicate as a duplicate.
         */
        return [
            'kind'      => $kind,
            'version'   => 1,
            'reference' => 'S-AA0000000000',
            'attack'    => [
                'id'            => 'aa000000-0000-4000-8000-000000000001',
                'destination'   => '10.0.0.1',
                'descriptor'    => 'Org One Site',
                'started_at'    => 1790550000,
                'finished_at'   => null,
                'gbps'          => 12.41,
                'mpps'          => 1.92
            ]
        ];
    }

    /**
     * Capture every webhook post, keyed by the URL it went to.
     */
    private function intercept(int $status = 200): \Closure
    {
        $posts = [];

        Http::fake(function (Request $request) use (&$posts, $status) {
            $posts[] = [ 'url' => $request->url(), 'body' => $request->data() ];

            return Http::response([ 'ok' => true ], $status);
        });

        return function () use (&$posts) {
            return $posts;
        };
    }

    /**
     * The core of the design: one stored payload, two very different deliveries. The chat platform
     * gets rendered words; the Raw hook gets the structured data itself, so a customer's automation
     * parses fields rather than scraping prose.
     */
    public function testChatHooksGetWordsAndRawHooksGetData(): void
    {
        $slack = $this->queue($this->hooks['slack']);
        $raw = $this->queue($this->hooks['raw']);

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $posts = collect($captured())->keyBy('url');

        $spoken = $posts['http://localhost:56000/hook/slack']['body'];

        $this->assertArrayHasKey('text', $spoken);
        $this->assertStringContainsString('Target: 10.0.0.1 (Org One Site)', $spoken['text']);
        $this->assertStringContainsString('Observed so far: 12.41 Gbps / 1.92 Mpps', $spoken['text']);
        $this->assertStringContainsString('Reference: S-AA0000000000', $spoken['text']);

        $data = $posts['http://localhost:56000/hook/raw']['body'];

        $this->assertSame('attack-start', $data['kind'], 'A raw hook receives the payload itself.');
        $this->assertSame('10.0.0.1', $data['attack']['destination']);
        $this->assertArrayNotHasKey('text', $data, 'No prose wrapper on the data delivery.');

        $this->assertSame(WebhookMessageStatus::Sent, $slack->fresh()->status);
        $this->assertSame(WebhookMessageStatus::Sent, $raw->fresh()->status);
    }

    /**
     * Discord is the third dialect: the same rendered words as Slack, delivered under `content` as the
     * platform expects rather than Slack's `text`.
     */
    public function testDiscordHooksGetWordsUnderContent(): void
    {
        $this->queue($this->hooks['discord']);

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $posts = collect($captured())->keyBy('url');
        $spoken = $posts['http://localhost:56000/hook/discord']['body'];

        $this->assertArrayHasKey('content', $spoken);
        $this->assertArrayNotHasKey('text', $spoken, 'Discord takes content, not text.');
        $this->assertStringContainsString('Target: 10.0.0.1 (Org One Site)', $spoken['content']);
    }

    /**
     * Isolation both ways: a legacy reporting message is not this sender's to touch, and a mitigation
     * message pointed at a legacy-usage hook has no business being delivered there.
     */
    public function testTheLegacyPipelineIsNeverTouched(): void
    {
        $legacy_message = new WebhookMessage([
            'key'               => 'as-legacy',
            'message_type'      => WebhookUsageType::Reporting->value,
            'payload'           => [ 'An old style line based notification.' ],
            'webhook_id'        => $this->hooks['legacy']->id,
            'status'            => WebhookMessageStatus::Pending->value,
            'sent_at'           => null,
            'organisation_id'   => 1
        ]);

        $legacy_message->save();

        $misdirected = $this->queue($this->hooks['legacy']);

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $this->assertSame([], $captured(), 'Nothing here was this sender\'s to deliver.');
        $this->assertSame(WebhookMessageStatus::Pending, $legacy_message->fresh()->status, 'The legacy message belongs to hook:report-send.');
        $this->assertSame(WebhookMessageStatus::Pending, $misdirected->fresh()->status, 'A reporting-usage hook receives no mitigation traffic.');
    }

    /**
     * The same payload twice on the same hook is one notification, not two - the duplicate settles
     * as sent without a second post.
     */
    public function testDuplicatePayloadsSettleWithoutResending(): void
    {
        $first = $this->queue($this->hooks['slack']);
        $second = $this->queue($this->hooks['slack']);

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $this->assertCount(1, $captured(), 'One post covers both rows.');
        $this->assertSame(WebhookMessageStatus::Sent, $first->fresh()->status);
        $this->assertSame(WebhookMessageStatus::Sent, $second->fresh()->status);
    }

    /**
     * An event kind the sender does not recognise must reach nobody - not worded wrongly to a chat
     * platform, not passed through raw as a delivery we never vetted. It fails as an error for us to
     * notice, on every hook type alike.
     */
    public function testAnUnknownKindFailsAndSendsNothing(): void
    {
        $spoken = $this->queue($this->hooks['slack'], $this->payload('attack-eclipse'));
        $data = $this->queue($this->hooks['raw'], $this->payload('attack-eclipse'));

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $this->assertSame([], $captured(), 'An unrecognised event must not be delivered anywhere.');
        $this->assertSame(WebhookMessageStatus::Error, $spoken->fresh()->status);
        $this->assertSame(WebhookMessageStatus::Error, $data->fresh()->status);
    }

    // A hook that answers badly marks the message errored rather than leaving it to retry forever.
    public function testAFailedDeliveryIsMarkedErrored(): void
    {
        $message = $this->queue($this->hooks['slack']);

        $this->intercept(503);

        $this->artisan('hook:process')->assertExitCode(0);

        $this->assertSame(WebhookMessageStatus::Error, $message->fresh()->status);
        $this->assertNotNull($message->fresh()->sent_at);
    }

    /**
     * Messages older than the freshness window are abandoned where they sit - a hook that was down
     * for an hour gets no backdated flood on recovery.
     */
    public function testStaleMessagesAreAbandoned(): void
    {
        $message = $this->queue($this->hooks['slack'], [], Carbon::now()->subMinutes(90));

        $captured = $this->intercept();

        $this->artisan('hook:process')->assertExitCode(0);

        $this->assertSame([], $captured());
        $this->assertSame(WebhookMessageStatus::Pending, $message->fresh()->status);
    }
}
