<?php

namespace App\Services\Deployment;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Throwable;

/**
 * What is actually running here.
 *
 * Read from the manifest the deployment writes when every step has SUCCEEDED —
 * not from the checkout, and not from live git. The checkout moves before the
 * migrations run; git tells you what the files say, not whether the deployment
 * finished. An operator asking "which version is this?" during a failed update
 * needs the answer "still the old one", and only a manifest written at the very
 * end can give it.
 *
 * Deliberately not cached in config either: `optimize` runs BEFORE the manifest
 * is written, so a cached copy would be one deployment behind forever.
 */
final readonly class Release
{
    private function __construct(
        /** The release number from VERSION, e.g. "1.0.0". */
        public string $version,
        /** The exact commit deployed, or null if nothing has deployed yet. */
        public ?string $commit,
        /** What it was deployed from: "refs/tags/v1.0.0" or "main". */
        public ?string $source,
        /** "release" — pinned to a tag — or "branch", following a line. */
        public string $mode,
        public ?Carbon $deployedAt,
        /** True when the deployed commit is exactly the one VERSION is tagged at. */
        public bool $isTaggedRelease,
    ) {}

    public static function current(): self
    {
        $manifest = self::manifest();

        // The version that DEPLOYED, not the one the checkout now claims. An
        // update moves the checkout before it migrates, so a run that fails in
        // between leaves a newer VERSION on disk than is actually serving —
        // and pairing that number with the old commit is worse than either
        // alone. Config is the fallback only until something has deployed.
        $version = isset($manifest['version']) && $manifest['version'] !== ''
            ? (string) $manifest['version']
            : (string) config('app.version', '0.0.0');

        return new self(
            version: $version,
            commit: isset($manifest['commit']) ? (string) $manifest['commit'] : null,
            source: isset($manifest['source']) ? (string) $manifest['source'] : null,
            mode: (string) ($manifest['mode'] ?? 'branch'),
            deployedAt: self::timestamp($manifest['deployed_at'] ?? null),
            // Only a deployment pinned to the tag is that release. Every commit
            // after v1.0.0 still carries VERSION=1.0.0 and is not 1.0.0.
            isTaggedRelease: isset($manifest['source'])
                && $manifest['source'] === "refs/tags/v{$version}",
        );
    }

    /** @return array<string, mixed> */
    private static function manifest(): array
    {
        $path = storage_path('app/deployment.json');

        if (! File::exists($path)) {
            return [];
        }

        return (array) (json_decode((string) File::get($path), true) ?: []);
    }

    /**
     * A timestamp that cannot take the console down.
     *
     * The manifest is written by a shell script during a deployment, and every
     * other field here is already treated as possibly corrupt. An unparseable
     * date throwing out of a layout partial would 500 every admin page — over a
     * line of small print in the sidebar.
     */
    private static function timestamp(mixed $value): ?Carbon
    {
        if (! is_string($value) || $value === '') {
            return null;
        }

        try {
            return Carbon::parse($value);
        } catch (Throwable) {
            return null;
        }
    }

    /** The short commit, for a footer that has no room for forty characters. */
    public function shortCommit(): ?string
    {
        return $this->commit !== null ? substr($this->commit, 0, 7) : null;
    }

    /**
     * One line, honest about what it is.
     *
     * "1.0.0 (abc1234)" only for a deployment pinned to that tag. Anything else
     * is somewhere after the release and says so — reporting a main deployment
     * as a clean 1.0.0 is how a bug report ends up filed against the wrong code.
     */
    public function label(): string
    {
        $commit = $this->shortCommit();

        if ($this->isTaggedRelease) {
            return $commit !== null ? "{$this->version} ({$commit})" : $this->version;
        }

        $line = $this->source !== null && $this->source !== ''
            ? ' · '.$this->source
            : '';

        return $commit !== null
            ? "{$this->version}-dev ({$commit}){$line}"
            : "{$this->version}-dev{$line}";
    }
}
