<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Backend;

use JTL\DB\DbInterface;
use JTL\Shop;
use Plugin\tnt_mcp\src\Mcp\OAuth\Entities\ShopUserEntity;
use stdClass;
use Throwable;

/**
 * Builds the `$_SESSION['AdminAccount']` payload for an existing `tadminlogin` row, so a backend
 * controller can be invoked out-of-band as a **real** admin instead of a synthetic superuser.
 *
 * Why this is safe to do without a password: `AdminAccount::validateSession()`
 * (AdminAccount.php:404-427) re-authenticates purely from the session payload — it selects
 * `tadminlogin` by `(cLogin, cPass)` where `cPass` is the **stored hash** written by `toSession()`
 * (AdminAccount.php:511). We reproduce that payload verbatim; we never learn, need, or store a
 * password, and we can only ever act as a row that already exists.
 *
 * Who we act as is decided by decision Q1: the admin who approved the OAuth consent, recorded on
 * the token as `cUserId` (see {@see ShopUserEntity}). Decision **Q2 supersedes design §1.5**: there
 * is no per-invocation permission scoping and no synthetic group — the acting admin's real
 * `tadminlogingruppe` row (including `kAdminlogingruppe === \ADMINGROUP`) is copied **verbatim**.
 * The catalog's `permission:` field is documentation, not a gate.
 *
 * This class takes a {@see DbInterface} and writes **no** superglobal. It produces the payload;
 * {@see GlobalStateSandbox} installs it.
 */
final class BackendIdentityFactory
{
    public function __construct(private readonly DbInterface $db)
    {
    }

    /**
     * Resolve the identity recorded on an OAuth token (`tnt_mcp_oauth_access_token.cUserId`).
     *
     * ⚠️ Tokens issued before admin-identity binding carry the literal
     * {@see ShopUserEntity::IDENTIFIER} (`'shop'`), which records *that* a consent happened but not
     * *who* approved it. That is rejected outright — see {@see LegacyTokenException}. It is a
     * privilege-escalation boundary, **not** a fallback: never substitute a "default" admin.
     *
     * @throws LegacyTokenException when the token predates admin-identity binding
     * @throws UnknownAdminException when the recorded login has no usable tadminlogin row
     */
    public function forTokenUser(string $userID): AdminIdentity
    {
        if ($userID === '' || $userID === ShopUserEntity::IDENTIFIER) {
            throw new LegacyTokenException(
                'This access token predates admin-identity binding (it records the resource owner as "'
                . ShopUserEntity::IDENTIFIER . '" instead of a backend login), so there is no admin to '
                . 'act as. Re-authorise the connection in your MCP client to issue a bound token.',
            );
        }

        return $this->forLogin($userID);
    }

    /**
     * @throws UnknownAdminException when $login has no active tadminlogin row
     */
    public function forLogin(string $login): AdminIdentity
    {
        $admin = $this->db->select('tadminlogin', 'cLogin', $login);
        if ($admin === null || !isset($admin->cLogin, $admin->cPass)) {
            throw new UnknownAdminException('No tadminlogin row for login "' . $login . '".');
        }

        $id      = (int)$admin->kAdminlogin;
        $groupID = (int)$admin->kAdminlogingruppe;

        // Same activity rules AdminAccount::login() enforces (AdminAccount.php:196-201): an
        // ADMINGROUP member is exempt from both, everybody else must be active and unexpired.
        if ($groupID !== \ADMINGROUP) {
            if ((int)($admin->bAktiv ?? 0) !== 1) {
                throw new UnknownAdminException('Admin "' . $login . '" is deactivated.');
            }
            $validUntil = $admin->dGueltigBis ?? null;
            if (\is_string($validUntil) && $validUntil !== '' && \strtotime($validUntil) < \time()) {
                throw new UnknownAdminException('Admin "' . $login . '" expired on ' . $validUntil . '.');
            }
        }

        $group = $this->groupWithPermissions($groupID);
        if ($group === null) {
            if ($groupID !== \ADMINGROUP) {
                // Mirrors toSession()'s guard (AdminAccount.php:505): without a group row and
                // without ADMINGROUP, core writes no session at all — so neither do we.
                throw new UnknownAdminException(
                    'Admin "' . $login . '" references permission group ' . $groupID . ', which does not exist.',
                );
            }
            $group                    = new stdClass();
            $group->kAdminlogingruppe = \ADMINGROUP;
        }

        return new AdminIdentity($id, (string)$admin->cLogin, $this->sessionPayload($admin, $id, $group));
    }

    /**
     * The eight properties `AdminAccount::toSession()` writes (AdminAccount.php:506-513 for the
     * seven scalars, :520 for `oGroup`), plus `TwoFA_valid`.
     *
     * `TwoFA_valid` is not part of `toSession()` — `validateSession()` reads it at
     * AdminAccount.php:420-421 and demands `=== true` whenever the row has `b2FAauth = 1`, and the
     * only writer is the interactive `doTwoFA()` flow (:487). We set it for such accounts because
     * the second factor **was** satisfied: the consent screen is only reachable behind
     * {@see \Plugin\tnt_mcp\src\Mcp\OAuth\JtlAdminSession::isAuthenticated()}, which requires
     * `AdminAccount::logged()` — i.e. `getIsTwoFaAuthenticated() && getIsAuthenticated()`
     * (AdminAccount.php:330-333). Setting it here re-states an already-proven fact; it does not
     * skip a check.
     */
    private function sessionPayload(stdClass $admin, int $id, stdClass $group): stdClass
    {
        $session              = new stdClass();
        $session->cURL        = \URL_SHOP;
        $session->kAdminlogin = $id;
        $session->cLogin      = $admin->cLogin;
        $session->cMail       = $admin->cMail ?? '';
        $session->cPass       = $admin->cPass;
        $session->language    = $admin->language ?? 'de-DE';
        $session->attributes  = $this->attributes($id);
        $session->oGroup      = $group;
        if ((int)($admin->b2FAauth ?? 0) === 1) {
            $session->TwoFA_valid = true;
        }

        return $session;
    }

    /**
     * Reimplementation of the private `AdminAccount::getPermissionsByGroup()`
     * (AdminAccount.php:564-585) — two plain reads, duplicated as *data* access, not as logic.
     */
    private function groupWithPermissions(int $groupID): ?stdClass
    {
        $group = $this->db->select('tadminlogingruppe', 'kAdminlogingruppe', $groupID);
        if ($group === null) {
            return null;
        }
        $group->kAdminlogingruppe = (int)$group->kAdminlogingruppe;
        $permissions              = [];
        foreach ($this->db->selectAll('tadminrechtegruppe', 'kAdminlogingruppe', $groupID, 'cRecht') as $row) {
            if (isset($row->cRecht)) {
                $permissions[] = (string)$row->cRecht;
            }
        }
        $group->oPermission_arr = $permissions;

        return $group;
    }

    /**
     * Reimplementation of the private `AdminAccount::getAttributes()` (AdminAccount.php:280-302),
     * including its SHOP-4319 catch-all and its avatar-URL rewrite (:293-296).
     *
     * @return array<string, stdClass>|null
     */
    private function attributes(int $userID): ?array
    {
        try {
            $attributes = [];
            $rows       = $this->db->getObjects(
                'SELECT cName, cAttribText, cAttribValue
                    FROM tadminloginattribut
                    WHERE kAdminlogin = :userID',
                ['userID' => $userID],
            );
            foreach ($rows as $row) {
                $attributes[(string)$row->cName] = $row;
            }
            if (isset($attributes['useAvatarUpload'])) {
                $attributes['useAvatarUpload']->cAttribValue = Shop::getImageBaseURL()
                    . \ltrim((string)$attributes['useAvatarUpload']->cAttribValue, '/');
            }
        } catch (Throwable) {
            return null;
        }

        return $attributes;
    }

    /**
     * Mint a CSRF token exactly the way the shop's own minter does
     * (`Form::getTokenInput()`, Helpers/Form.php:50) so a sandboxed invocation can submit a **real**
     * token: `$_SESSION['jtl_token']` = this value, `$_POST['jtl_token']` / `$_GET['token']` = the
     * same value. Caller installs them via the sandbox; this method only produces the string.
     *
     * ⚠️ Do not "optimise" this into a bypass. We never patch `Form::validateToken()`, never inject
     * a skip-CSRF flag, and never reach into `CryptoService::stableStringEquals()`. CSRF is a
     * *browser* threat model — it defends against a cross-origin page making an authenticated user's
     * browser submit a form. In-process there is no browser and no cross-origin attacker, so the
     * token here proves nothing and cannot be made to prove anything; the real authorisation
     * boundary for MCP invocations is the bearer token
     * ({@see \Plugin\tnt_mcp\src\Mcp\Auth\TokenGuard}) plus the OAuth consent that bound this
     * identity. Minting honestly keeps the shop's own check intact and untouched, which is the
     * point: the day core changes how tokens are validated, we inherit the change instead of
     * having to notice it.
     */
    public static function mintCsrfToken(): string
    {
        return Shop::Container()->getCryptoService()->randomString(32);
    }
}
