<?php

use App\Livewire\Admin\ProcessingAgreements;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\DpaAcceptance;
use App\Models\DpaVersion;
use App\Models\User;
use App\Services\Legal\ProcessingAgreement;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

/**
 * The processing agreement, and the proof a customer has it.
 *
 * Art. 28(3) GDPR wants a contract wherever personal data is processed on
 * somebody else's behalf, which is the whole of what this product does. "In
 * writing" there includes electronic form (Art. 28(9)), so a document the
 * customer can read plus a recorded acceptance is enough — no signature on
 * paper. The website already promises "AV-Vertrag inklusive", which means it has
 * to be obtainable without asking for it.
 *
 * The TEXT comes from one of two places, and the console does not care which:
 * generated from this repository (resources/views/legal/dpa, via
 * clupilot:publish-dpa) or uploaded as a lawyer's revision.
 */
function dpaCustomer(): array
{
    $user = User::factory()->create(['email_verified_at' => now()]);
    $customer = Customer::factory()->create(['email' => $user->email, 'user_id' => $user->id]);

    return [$user, $customer];
}

function publishedDpa(string $version = '1.0', bool $withMeasures = true): DpaVersion
{
    Storage::fake('local');

    return DpaVersion::query()->create([
        'version' => $version,
        'agreement_path' => UploadedFile::fake()->create('avv.pdf', 12, 'application/pdf')->store('dpa', 'local'),
        'measures_path' => $withMeasures
            ? UploadedFile::fake()->create('tom.pdf', 12, 'application/pdf')->store('dpa', 'local')
            : null,
        'published_at' => now()->subDay(),
    ]);
}

it('shows the customer nothing at all until a version is in force', function () {
    // A card offering an agreement that does not exist is worse than silence.
    [$user] = dpaCustomer();

    Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
        ->assertDontSee(__('dpa.title'));
});

it('offers the document and records the acceptance with its evidence', function () {
    $version = publishedDpa();
    [$user, $customer] = dpaCustomer();

    $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']);

    // The agreement is concluded WITH the terms at checkout, so the page states
    // that rather than nagging — and offers the extra confirmation for whoever
    // wants one on file.
    $page->assertSee(__('dpa.title'))
        ->assertSee(__('dpa.in_force_note'))
        ->assertSee(__('dpa.accept_cta'))
        ->call('acceptProcessingAgreement')
        ->assertDispatched('notify');

    $acceptance = DpaAcceptance::query()->where('customer_id', $customer->id)->sole();

    // What makes it evidence rather than a flag: the version, the moment, the
    // login that pressed it, and where from.
    expect($acceptance->dpa_version_id)->toBe($version->id)
        ->and($acceptance->user_id)->toBe($user->id)
        ->and($acceptance->accepted_at)->not->toBeNull()
        ->and($acceptance->ip)->not->toBeNull();

    // And the card states the confirmation rather than asking again.
    Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
        ->assertSee($acceptance->accepted_at->local()->isoFormat('LL, LT'))
        ->assertDontSee(__('dpa.accept_cta'));
});

it('counts pressing twice as one agreement', function () {
    publishedDpa();
    [$user, $customer] = dpaCustomer();

    Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
        ->call('acceptProcessingAgreement')
        ->call('acceptProcessingAgreement');

    expect(DpaAcceptance::query()->where('customer_id', $customer->id)->count())->toBe(1);
});

it('asks again when a new version comes into force, and keeps the old acceptance', function () {
    // Acceptance is per version: a new one supersedes the last, and accepting a
    // superseded document is not accepting the one in force. The old row stays —
    // it was true when it was made, and the history is the point.
    $first = publishedDpa('1.0');
    [$user, $customer] = dpaCustomer();

    app(ProcessingAgreement::class)->accept($customer);

    expect(app(ProcessingAgreement::class)->accepted($customer))->toBeTrue();

    $second = DpaVersion::query()->create([
        'version' => '2.0',
        'agreement_path' => $first->agreement_path,
        'published_at' => now(),
    ]);

    expect(app(ProcessingAgreement::class)->current()->id)->toBe($second->id)
        ->and(app(ProcessingAgreement::class)->accepted($customer))->toBeFalse()
        // Not deleted, not overwritten.
        ->and(DpaAcceptance::query()->where('dpa_version_id', $first->id)->exists())->toBeTrue();
});

it('serves the document to the customer, and only the one in force', function () {
    publishedDpa();
    [$user] = dpaCustomer();

    $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertOk();
    $this->actingAs($user)->get(route('dpa.file', 'measures'))->assertOk();

    // Which document applies is ours to say, not a parameter somebody can put
    // in the address.
    $this->actingAs($user)->get(route('dpa.file', 'sonstwas'))->assertNotFound();
});

it('does not hand the agreement to a stranger', function () {
    publishedDpa();

    $this->get(route('dpa.file', 'agreement'))->assertRedirect();
});

it('answers 404 rather than 500 when nothing is published', function () {
    [$user] = dpaCustomer();

    $this->actingAs($user)->get(route('dpa.file', 'agreement'))->assertNotFound();
});

// ---- The console ----

it('takes an upload as a draft, and only a second act puts it in force', function () {
    // Publishing leaves every customer who accepted the previous version
    // outstanding again. That is correct, and expensive to trigger by dropping a
    // file on a form.
    Storage::fake('local');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ProcessingAgreements::class)
        ->set('version', '3.0')
        ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf'))
        ->call('upload')
        ->assertHasNoErrors();

    $version = DpaVersion::query()->where('version', '3.0')->sole();

    expect($version->isPublished())->toBeFalse()
        ->and(app(ProcessingAgreement::class)->current())->toBeNull()
        ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue();

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ProcessingAgreements::class)
        ->call('publish', $version->uuid);

    expect(app(ProcessingAgreement::class)->current()?->id)->toBe($version->id);
});

it('takes the agreement as a PDF and nothing else', function () {
    Storage::fake('local');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ProcessingAgreements::class)
        ->set('version', '4.0')
        ->set('agreement', UploadedFile::fake()->create('avv.docx', 20, 'application/msword'))
        ->call('upload')
        ->assertHasErrors(['agreement']);
});

it('refuses two versions under one name', function () {
    publishedDpa('1.0');
    Storage::fake('local');

    Livewire::actingAs(operator('Owner'), 'operator')
        ->test(ProcessingAgreements::class)
        ->set('version', '1.0')
        ->set('agreement', UploadedFile::fake()->create('avv.pdf', 20, 'application/pdf'))
        ->call('upload')
        ->assertHasErrors(['version']);
});

it('keeps the whole thing to operators who may manage it', function () {
    // Its own capability: whoever keeps the platform running does not thereby
    // decide what every customer is asked to agree to.
    Livewire::actingAs(operator('Read-only'), 'operator')
        ->test(ProcessingAgreements::class)
        ->assertForbidden();

    $this->actingAs(operator('Read-only'), 'operator')
        ->get(route('admin.dpa'))
        ->assertForbidden();
});

it('confirms publication in a modal rather than on one click', function () {
    // R23, and for a reason beyond the rule: this is the click that makes every
    // customer outstanding again.
    $page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/processing-agreements.blade.php'));

    expect($page)->toContain("component: 'admin.confirm-publish-dpa'");

    $modal = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/ConfirmPublishDpa.php'));

    expect($modal)->toContain("dispatch('dpa-publish-confirmed'")
        // The modal decides nothing: the permission check stays where it was.
        ->and($modal)->not->toContain('published_at');
});

// ---- The documents this repository generates ----

it('renders both documents as real PDFs, from the company on record', function () {
    // Generated rather than uploaded, so the agreement cannot name the company
    // differently from the invoices — and a wording change is a diff somebody
    // can review.
    App\Support\CompanyProfile::put(['name' => 'Prüf GmbH', 'city' => 'Wien', 'country' => 'Österreich']);

    $renderer = app(App\Services\Legal\DpaRenderer::class);

    $agreement = $renderer->agreement('9.9');
    $measures = $renderer->measures('9.9');

    expect(substr($agreement, 0, 4))->toBe('%PDF')
        ->and(substr($measures, 0, 4))->toBe('%PDF')
        ->and(strlen($agreement))->toBeGreaterThan(20000)
        ->and(strlen($measures))->toBeGreaterThan(20000);
});

it('names every place customer data actually leaves this application', function () {
    // A sub-processor list that quietly omits one is the single most common
    // defect in a processing agreement — hosting, payment and mail are the three
    // here, and each is named with where it processes.
    $names = collect(App\Services\Legal\DpaRenderer::subprocessors())->pluck('name')->implode(' ');

    expect($names)->toContain('Hetzner')
        ->and($names)->toContain('Stripe')
        ->and(App\Services\Legal\DpaRenderer::subprocessors())->each->toHaveKeys(['name', 'service', 'location']);
});

it('publishes a generated version in one command, and refuses to reuse a name', function () {
    Storage::fake('local');

    $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertSuccessful();

    $version = DpaVersion::query()->where('version', '7.0')->sole();

    expect($version->isPublished())->toBeTrue()
        ->and(Storage::disk('local')->exists($version->agreement_path))->toBeTrue()
        ->and(Storage::disk('local')->exists($version->measures_path))->toBeTrue();

    // Two documents under one name makes every acceptance ambiguous.
    $this->artisan('clupilot:publish-dpa', ['version' => '7.0'])->assertFailed();
});

it('keeps a drafted version out of force', function () {
    Storage::fake('local');

    $this->artisan('clupilot:publish-dpa', ['version' => '8.0', '--draft' => true])->assertSuccessful();

    expect(DpaVersion::query()->where('version', '8.0')->sole()->isPublished())->toBeFalse()
        ->and(app(ProcessingAgreement::class)->current())->toBeNull();
});

it('hands the file over under a name carrying the version', function () {
    // "Which fassung did I agree to" has to be answerable from a downloads
    // folder months later, without opening anything.
    publishedDpa('2.5');
    [$user] = dpaCustomer();

    $this->actingAs($user)
        ->get(route('dpa.file', ['which' => 'agreement', 'download' => 1]))
        ->assertOk()
        ->assertDownload('CluPilot-AV-Vertrag-2.5.pdf');

    $this->actingAs($user)
        ->get(route('dpa.file', ['which' => 'measures', 'download' => 1]))
        ->assertOk()
        ->assertDownload('CluPilot-TOM-2.5.pdf');
});

it('leaves the documents readable by the process that has to serve them', function () {
    // The failure this prevents had no error message anywhere: an artisan run in
    // a container is root, PHP-FPM is www-data, and Laravel's local disk creates
    // a private directory as 0700. The web process could not enter it, the
    // route's exists() check answered false, and every link on the page 404'd
    // while the file sat there perfectly intact.
    //
    // "public" here is the file MODE and nothing to do with the web: this disk's
    // root is outside the document root either way.
    Storage::fake('local');

    $this->artisan('clupilot:publish-dpa', ['version' => '6.1'])->assertSuccessful();

    $version = DpaVersion::query()->where('version', '6.1')->sole();

    expect(Storage::disk('local')->getVisibility($version->agreement_path))->toBe('public')
        ->and(Storage::disk('local')->getVisibility($version->measures_path))->toBe('public');
});

it('serves the console download too, not only the portal one', function () {
    publishedDpa('3.3');
    $version = DpaVersion::query()->where('version', '3.3')->sole();

    $this->actingAs(operator('Owner'), 'operator')
        ->get(route('admin.dpa.file', ['uuid' => $version->uuid, 'which' => 'agreement', 'download' => 1]))
        ->assertOk()
        ->assertDownload('CluPilot-AV-Vertrag-3.3.pdf');
});

it('never treats the agreement as something outstanding', function () {
    // Art. 28 wants a contract, not a ceremony: it is concluded with the terms
    // at checkout (see the AGB), so the page must not imply that a customer who
    // has not clicked is missing something. Hosters that ask for a separate
    // signature are the exception, not the rule.
    publishedDpa();
    [$user] = dpaCustomer();

    $page = Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])->html();

    expect($page)->toContain(__('dpa.in_force_note'))
        // No warning colour, no "open" chip: the state is "available", and it is.
        ->and($page)->not->toContain('text-warning">'.__('dpa.title'));
});

it('says in the terms that the agreement comes with them', function () {
    // Which is what makes the click optional rather than a gap.
    $terms = $this->get(route('legal.agb'))->assertOk()->getContent();

    expect($terms)->toContain('Auftragsverarbeitung')
        ->and($terms)->toContain('gesonderten Unterzeichnung')
        ->and($terms)->toContain('Kundenbereich');
});
