<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Admin;

use JTL\Backend\Permissions;
use JTL\Shop;
use Plugin\tnt_mcp\src\Mcp\Action\ActionCatalog;
use ReflectionClass;
use Throwable;

/**
 * Decision **Q2**: an MCP client acts with exactly the backend rights of the administrator who
 * approved its OAuth consent — there is no per-invocation permission scoping and no synthetic
 * group ({@see \Plugin\tnt_mcp\src\Mcp\Backend\BackendIdentityFactory}). The only place a
 * permission is actually enforced is
 * `BackendActionInvoker::dispatchIo()` (src/Mcp/Backend/BackendActionInvoker.php:322-327), and it
 * enforces the *acting admin's* rights.
 *
 * That has a consequence worth saying out loud at install time: an administrator who does not hold
 * the permissions the shipped action catalog declares will connect an MCP client successfully and
 * then watch individual actions fail — with a permission error that reads like a plugin bug. This
 * class turns that into a named, up-front list.
 *
 * Pure: it reads the YAML catalog and a permission list handed in by the caller. No DB, no `Shop::`
 * — so it is unit-testable and usable from the install hook. The single exception is the explicitly
 * named {@see self::currentAdmin()}, which reads the backend session so callers do not each
 * re-derive it.
 */
final class PermissionAudit
{
    /**
     * Permissions the plugin's non-catalog tools imply. These are NOT enforced anywhere — the read
     * tools talk to the DB / settings bridge directly, they never enter a backend controller — so
     * they are listed as "what a human would need to do the same thing in the backend", which is
     * the honest answer to "what is this connection able to see?".
     *
     * @var array<string, list<string>> permission CONSTANT name => the tools it corresponds to
     */
    private const TOOL_PERMISSIONS = [
        'SETTINGS_GLOBAL_VIEW' => ['list_settings', 'get_setting', 'describe_setting', 'set_setting'],
        'DBCHECK_VIEW'         => ['list_tables', 'describe_table', 'run_sql_read'],
        'SYSTEMLOG_VIEW'       => ['read_logs'],
    ];

    public function __construct(private readonly ActionCatalog $catalog)
    {
    }

    /** The catalog this plugin actually ships. */
    public static function forShippedCatalog(): self
    {
        return new self(ActionCatalog::fromDirectory(\dirname(__DIR__, 3) . '/catalog/actions'));
    }

    /**
     * Every permission the plugin can end up exercising, with the reason it is needed.
     *
     * @return array<string, list<string>> permission CONSTANT name => action ids / tool names, sorted
     */
    public function required(): array
    {
        $required = self::TOOL_PERMISSIONS;
        foreach ($this->catalog->all() as $spec) {
            if ($spec->permission === '') {
                continue;
            }
            $required[$spec->permission][] = $spec->id;
        }

        foreach ($required as $permission => $reasons) {
            $reasons = \array_values(\array_unique($reasons));
            \sort($reasons);
            $required[$permission] = $reasons;
        }
        \ksort($required);

        return $required;
    }

    /**
     * Permission constant names the catalog declares that do not exist on {@see Permissions} — a
     * broken catalog, reported rather than silently treated as "held" or "missing".
     *
     * @return list<string>
     */
    public function unknown(): array
    {
        $known = self::knownPermissions();
        $out   = [];
        foreach (\array_keys($this->required()) as $permission) {
            if (!isset($known[$permission])) {
                $out[] = $permission;
            }
        }

        return $out;
    }

    /**
     * @param int          $groupID     the admin's `kAdminlogingruppe`
     * @param list<string> $held        the admin's `tadminrechtegruppe.cRecht` values
     * @return list<string> permission constant names the admin does NOT hold, sorted
     */
    public function missing(int $groupID, array $held): array
    {
        // The identical short-circuit AdminAccount::permission() applies (AdminAccount.php:375-377):
        // an ADMINGROUP member holds everything and the list is never consulted.
        if ($groupID === \ADMINGROUP) {
            return [];
        }

        $heldSet = \array_flip($held);
        $known   = self::knownPermissions();
        $out     = [];
        foreach ($this->required() as $permission => $reasons) {
            // An unknown constant cannot be held, but it is a catalog defect, not an admin's fault —
            // reported by unknown(), not counted here.
            if (!isset($known[$permission]) || isset($heldSet[$permission])) {
                continue;
            }
            $out[] = $permission;
        }

        return $out;
    }

    /**
     * The full finding for one administrator, ready to render or to push into a backend
     * notification.
     *
     * @param list<string> $held
     * @return array{
     *     ok: bool, login: string, missing: list<string>, unknown: list<string>,
     *     required: array<string, list<string>>, reasons: array<string, list<string>>,
     *     title: array{de: string, en: string}, text: array{de: string, en: string}
     * }
     */
    public function report(string $login, int $groupID, array $held): array
    {
        $required = $this->required();
        $missing  = $this->missing($groupID, $held);
        $unknown  = $this->unknown();
        $reasons  = [];
        foreach ($missing as $permission) {
            $reasons[$permission] = $required[$permission] ?? [];
        }

        return [
            'ok'       => $missing === [],
            'login'    => $login,
            'missing'  => $missing,
            'unknown'  => $unknown,
            'required' => $required,
            'reasons'  => $reasons,
            'title'    => [
                'de' => $missing === []
                    ? 'MCP Server: Rechteprüfung bestanden'
                    : 'MCP Server: fehlende Backend-Rechte',
                'en' => $missing === []
                    ? 'MCP Server: permission check passed'
                    : 'MCP Server: missing backend permissions',
            ],
            'text' => [
                'de' => $this->textDe($login, $missing, $reasons),
                'en' => $this->textEn($login, $missing, $reasons),
            ],
        ];
    }

    /**
     * @param list<string>                $missing
     * @param array<string, list<string>> $reasons
     */
    private function textDe(string $login, array $missing, array $reasons): string
    {
        if ($missing === []) {
            return 'Der Administrator „' . $login . '“ besitzt alle Backend-Rechte, die der '
                . 'MCP-Server benötigt. Ein MCP-Client handelt immer mit genau den Rechten des '
                . 'Administrators, der die Verbindung freigegeben hat.';
        }

        $lines = [];
        foreach ($missing as $permission) {
            $lines[] = $permission . ' (benötigt für: ' . \implode(', ', $reasons[$permission] ?? []) . ')';
        }

        return 'Dem Administrator „' . $login . '“ fehlen ' . \count($missing) . ' Backend-Recht(e), '
            . 'die der MCP-Server benötigt: ' . \implode('; ', $lines) . '. '
            . 'Ein MCP-Client handelt immer mit genau den Rechten des Administrators, der die '
            . 'Verbindung freigegeben hat — fehlende Rechte führen dazu, dass die betroffenen '
            . 'Aktionen zur Laufzeit abgelehnt werden. Ergänzen Sie die Rechte unter '
            . 'Administration → Benutzerverwaltung → Rechtegruppen, oder geben Sie die Verbindung '
            . 'mit einem Administrator frei, der sie besitzt.';
    }

    /**
     * @param list<string>                $missing
     * @param array<string, list<string>> $reasons
     */
    private function textEn(string $login, array $missing, array $reasons): string
    {
        if ($missing === []) {
            return 'Administrator "' . $login . '" holds every backend permission the MCP server '
                . 'needs. An MCP client always acts with exactly the permissions of the '
                . 'administrator who approved the connection.';
        }

        $lines = [];
        foreach ($missing as $permission) {
            $lines[] = $permission . ' (needed for: ' . \implode(', ', $reasons[$permission] ?? []) . ')';
        }

        return 'Administrator "' . $login . '" is missing ' . \count($missing) . ' backend '
            . 'permission(s) the MCP server needs: ' . \implode('; ', $lines) . '. '
            . 'An MCP client always acts with exactly the permissions of the administrator who '
            . 'approved the connection — missing permissions mean the affected actions are refused '
            . 'at run time. Add them under Administration → User management → Permission groups, or '
            . 'approve the connection with an administrator who has them.';
    }

    /**
     * The logged-in backend administrator, read straight off the session the way
     * `AdminAccount::permission()` reads it (AdminAccount.php:373-378). `null` when nobody is
     * logged in — e.g. a CLI install.
     *
     * @return array{login: string, groupId: int, permissions: list<string>}|null
     */
    public static function currentAdmin(): ?array
    {
        try {
            $account = Shop::Container()->getAdminAccount()->account();
        } catch (Throwable) {
            return null;
        }
        if ($account === false || !isset($account->cLogin)) {
            return null;
        }

        $permissions = $account->oGroup->oPermission_arr ?? [];

        return [
            'login'       => (string)$account->cLogin,
            'groupId'     => (int)($account->oGroup->kAdminlogingruppe ?? 0),
            'permissions' => \is_array($permissions)
                ? \array_values(\array_map('\strval', $permissions))
                : [],
        ];
    }

    /**
     * @return array<string, true> constant NAME => true
     */
    private static function knownPermissions(): array
    {
        try {
            $constants = (new ReflectionClass(Permissions::class))->getConstants();
        } catch (Throwable) {
            return [];
        }

        $out = [];
        foreach ($constants as $name => $value) {
            if (\is_string($value)) {
                $out[(string)$name] = true;
            }
        }

        return $out;
    }
}
