<?php

namespace App\Services\Billing;

use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Services\Secrets\SecretCipher;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

/**
 * A copy of an invoice on one destination.
 *
 * Two kinds, and the difference is where the protocol lives.
 *
 * 'local' is a directory this machine can already write to — a NAS mounted over
 * NFS 4.1, a bind mount, a disk. The application knows nothing about the
 * protocol and does not want to: the mount is the operator's business, the
 * target can change without a line of code changing with it, and the whole
 * thing stays testable against a temporary directory.
 *
 * 'sftp' is for something reachable over the network with credentials that is not
 * mounted. The password is encrypted on the
 * row with the same cipher the credential vault uses: a credential in a
 * settings table in the clear is a credential in every database dump.
 *
 * Neither is allowed to create its own root. See ensureRoot().
 */
final class InvoiceArchive
{
    public function __construct(
        private readonly InvoiceRenderer $renderer,
        private readonly SecretCipher $cipher,
    ) {}

    /**
     * Write one invoice to one destination.
     *
     * Throws rather than returning false: the caller is a queued job, and a
     * throw is what makes the queue retry. A silent false would leave the
     * invoice unarchived and the job marked done — the failure nobody notices.
     */
    public function store(Invoice $invoice, ExportTarget $target): string
    {
        // BEFORE the disk is built, and that order is the point: Laravel's
        // local adapter creates its root when it is constructed, so a check
        // afterwards always finds the directory it just made — which is exactly
        // the silent-success this guard exists to prevent.
        $this->ensureRoot($target);

        $disk = $this->disk($target);

        // Per year for an archive somebody keeps, per day for a handover point
        // somebody collects from — the destination says which, because the two
        // do different jobs. Either way it is grouped: seven years of invoices
        // in one directory is a directory nobody opens twice.
        $relative = $target->folderFor($invoice->issued_on).'/'.$invoice->number.'.pdf';

        $pdf = $this->renderer->forInvoice($invoice);

        // Written beside the final name and moved into place. A move within one
        // filesystem is atomic, so whatever watches that folder never sees a
        // truncated PDF and takes it for a finished invoice.
        $temporary = $relative.'.part';

        if (! $disk->put($temporary, $pdf)) {
            throw new RuntimeException("Could not write to {$target->name}: {$temporary}");
        }

        $disk->delete($relative);

        if (! $disk->move($temporary, $relative)) {
            $disk->delete($temporary);

            throw new RuntimeException("Could not move the finished file into place on {$target->name}: {$relative}");
        }

        // Read the size back rather than trusting the write. Over a network
        // mount a short write can report success, and an archive of truncated
        // files looks exactly like one that worked.
        $written = $disk->size($relative);

        if ($written < 1000) {
            throw new RuntimeException("The archived file on {$target->name} is too small to be an invoice: {$relative}");
        }

        return $relative;
    }

    /**
     * The root is used, never created.
     *
     * This is the whole difference between an archive and a hole in the ground.
     * mkdir -p on a path whose mount is not there SUCCEEDS: it creates the empty
     * directory beneath the mountpoint, writes the invoice onto the container's
     * own disk and reports success — so an unmounted NAS would quietly collect
     * invoices locally while every check said it worked. A missing root means a
     * missing mount, and that is a failure to report, not a directory to make.
     *
     * Only for 'local'. On sftp the root is a remote directory that the server
     * genuinely may need to create, and there is no mountpoint to be confused
     * with.
     */
    private function ensureRoot(ExportTarget $target): void
    {
        if ($target->driver !== ExportTarget::LOCAL) {
            return;
        }

        if (! is_dir($target->path)) {
            throw new RuntimeException(
                "The path for {$target->name} does not exist — is the mount there? {$target->path}"
            );
        }
    }

    private function disk(ExportTarget $target): FilesystemAdapter
    {
        if ($target->driver === ExportTarget::SFTP) {
            $stored = trim((string) $target->secret_key);

            if ($stored === '') {
                throw new RuntimeException("No credential stored for {$target->name}.");
            }

            $password = $this->cipher->decrypt($stored);

            if ($password === '') {
                // A rotated SECRETS_KEY makes every stored credential
                // unreadable. Saying so beats an authentication failure against
                // a host that is perfectly fine.
                throw new RuntimeException("The credential for {$target->name} cannot be decrypted — has SECRETS_KEY changed?");
            }

            return Storage::build([
                'driver' => 'sftp',
                'host' => (string) $target->host,
                'port' => (int) ($target->port ?: 22),
                'username' => (string) $target->username,
                'password' => $password,
                'root' => $target->path,
                'timeout' => 30,
            ]);
        }

        return Storage::build([
            'driver' => 'local',
            'root' => $target->path,
            'throw' => false,
            // Explicit, because the default follows the process umask and a
            // restrictive one leaves the folder 0700. That is not cosmetic:
            // whoever COLLECTS from a handover directory is a different account
            // — an rsync over ssh, a backup agent — and a directory it cannot
            // enter looks to it exactly like an empty one. Reported as "the
            // folder arrives empty", with the invoice sitting in it the whole
            // time.
            'visibility' => 'public',
            'permissions' => [
                'file' => ['public' => 0o644, 'private' => 0o600],
                'dir' => ['public' => 0o755, 'private' => 0o700],
            ],
        ]);
    }
}
