<?php

namespace WHMCS\Module\Addon\creatia_button\Client;

use Exception;
use Throwable;
use WHMCS\Database\Capsule;
use WHMCS\Module\Addon\creatia_button\Eligibility;

class Controller {

    private const ADDRESS_OK          = 'ok';
    private const ADDRESS_UNVERIFIED  = 'unverified';
    private const ADDRESS_MISMATCH    = 'mismatch';

    /**
     * Addon settings, read once per request.
     *
     * @var array<string,string>|null
     */
    private ?array $settings = null;

    /**
     * Result of addressStatus() for this request.
     *
     * @var string|null
     */
    private ?string $addressStatus = null;

    /* ------------------------------------------------------------------ *
     * Actions
     * ------------------------------------------------------------------ */

    /**
     * Landing page.
     *
     * @param array $vars Module configuration parameters
     * @return array
     */
    public function index($vars) {

        $client = $this->client();

        if ($client === null || !Eligibility::clientQualifies((int) $client->id, $this->settings())) {
            return $this->requiredPage($client);
        }

        $subtenant = $this->setting('creatia_subtenant', 'gsl');

        return $this->page(
            'Setup Creatia Account',
            $subtenant === 'gsl' ? 'main-gsl' : 'main-brands',
            [
                'csrfToken' => $this->csrfToken(),
                'readOnly'  => $this->setting('access_level', '1') !== '0',
            ]
        );
    }

    /**
     * Provision (or resume provisioning of) a Creatia account for the current client.
     *
     * @param array $vars Module configuration parameters
     * @return array
     */
    public function setup($vars) {

        $client = $this->client();

        if ($client === null || !Eligibility::clientQualifies((int) $client->id, $this->settings())) {
            return $this->requiredPage($client);
        }

        // Provisioning is a write, so it only happens on a posted form carrying this
        // session's token. As a link it could be fired by any third party page, an
        // email client fetching images, or a prefetcher. A stale or absent token is
        // not an error worth showing - just hand back the page with the button on it.
        if (!$this->requestIsGenuine()) {
            return $this->index($vars);
        }

        try {
            return $this->provision($client);
        }
        catch (Throwable $ex) {
            // Catch Throwable, not Exception: a malformed response surfaces as a TypeError
            // rather than an Exception, and either way this must not reach the client as a
            // fatal - the exception text can carry raw API output.
            logActivity('Creatia Button: provisioning failed - ' . $ex->getMessage(), (int) $client->id);
        }

        return $this->page('Something Went Wrong', 'failure', $this->reference($client));
    }

    /* ------------------------------------------------------------------ *
     * Provisioning
     * ------------------------------------------------------------------ */

    /**
     * Work out what state this client is in and move them one step forward.
     *
     * The external identity and the email address decide between "already done", "finish
     * a provision that stopped half way", "link the organisation this address already
     * signs in to", "a human needs to look at this", and "nothing exists, create it".
     * Anything that adds to an organisation which already exists needs a verified address.
     *
     * @param object $client WHMCS client
     * @return array
     */
    private function provision($client): array {

        $clientId    = (int) $client->id;
        $email       = (string) $client->email;
        $identityKey = $this->setting('creatia_identity_key', 'gsl');
        $subtenant   = $this->setting('creatia_subtenant', 'gsl');

        // '0' is read/write, '1' is read only. Fail closed: anything else, including an
        // unset value, provisions a read-only account rather than a privileged one.
        $readonly = $this->setting('access_level', '1') !== '0';

        $api = new PhpCreatia(
            (string) $this->setting('creatia_provision_url'),
            (string) $this->setting('creata_api_identity'),
            (string) $this->setting('creatia_api_secret')
        );

        // 1. Is this WHMCS client already bound to an organisation?
        $linked = $api->getExternalIdentities($identityKey, (string) $clientId);

        $organisations = array_unique(array_column($linked['data'] ?? [], 'creatia_id'));

        // More than one organisation holding this client's identity is ambiguous, and
        // picking a row would mean writing into an arbitrary one. update_organisation_v2's
        // 'add' mode performs no uniqueness check, so the state is reachable.
        if (sizeof($organisations) > 1) {
            throw new Exception('identity ' . $clientId . ' is held by ' . sizeof($organisations) . ' organisations (' . implode(', ', $organisations) . ') - resolve by hand');
        }

        $organisationId = $organisations === [] ? null : reset($organisations);

        // 2. Does a Creatia user already exist for this email address?
        $creatiaUser  = $api->getUser($email);
        $existingUser = $creatiaUser['user'] ?? null;
        $existingOrg  = $creatiaUser['organisation_id'] ?? null;

        // 3. Bound, with a user against this address. Nothing to do.
        if ($organisationId !== null
            && $existingUser !== null
            && (string) $existingOrg === (string) $organisationId) {

            return $this->page('Already Have Account', 'already', [
                'billingEmail' => $email,
            ] + $this->reference($client));
        }

        // 4. Bound to an organisation, but no user against this address. A previous
        //    attempt created the organisation and then failed, so finish it.
        //
        //    Safe because the identity is written by this module and by Mercury, and both
        //    write the WHMCS client ID - so a row for this client resolves to this
        //    client's own organisation. Refusing here instead would strand them: the
        //    organisation exists, so creating another is rejected as a duplicate
        //    identifier, and every retry would land on the same page forever.
        if ($organisationId !== null && $existingUser === null) {

            // Adding to an organisation that already existed, so the address has to be
            // proven first. A first-time provision does not need it: the invitation goes to
            // the address itself, so claiming one you do not control gains nothing - and
            // requiring it there would break signing up and ordering in one sitting, before
            // the verification mail has even been opened.
            $addressStatus = $this->addressStatus($client);

            if ($addressStatus !== self::ADDRESS_OK) {
                return $this->addressPage($addressStatus, $client);
            }

            $this->createUser($api, (string) $organisationId, $client, $readonly);

            return $this->page('Provisioning In Progress', 'success', [
            'billingEmail' => $email,
        ] + $this->reference($client));
        }

        // 5. Not bound, but the address is already a login in an organisation. Link that
        //    organisation to this client - the same rule Mercury applies when an order
        //    arrives in this state.
        //
        //    Only from a verified address, which shows the client controls the login rather
        //    than merely naming it. It shows membership, not ownership: a team member's own
        //    billing account links to the organisation they were invited into. That is
        //    deliberate - it is the organisation they sign in to.
        //
        //    Only on the GSL install. A brand's dedi right promotes every read-only user in
        //    the organisation (add_product_right), so there a read-only colleague who linked
        //    and then bought a server would promote them all. Nothing GSL grants does that.
        if ($subtenant === 'gsl' && $organisationId === null && $existingUser !== null && $existingOrg !== null) {

            $addressStatus = $this->addressStatus($client);

            if ($addressStatus !== self::ADDRESS_OK) {
                return $this->addressPage($addressStatus, $client);
            }

            $api->updateOrganisation((string) $existingOrg, [
                [ 'key' => $identityKey, 'identity' => (string) $clientId, 'mode' => 'add' ]
            ]);

            logActivity(
                'Creatia Button: linked existing Creatia organisation ' . $existingOrg . ' to this client, '
                . 'since their verified address ' . $email . ' already has a login there '
                . '(subtenant ' . ($creatiaUser['subtenant'] ?? 'unknown') . ', this install ' . $subtenant . ').',
                $clientId
            );

            return $this->page('Already Have Account', 'already', [
                'billingEmail' => $email,
            ] + $this->reference($client));
        }

        // 6. Anything else involving a pre-existing organisation goes to an administrator
        //    rather than being written to: a client bound to one organisation whose address
        //    signs in to another (linking would leave the identity held by two, which
        //    nothing downstream can resolve), a login with no organisation, or any existing
        //    login on a brand install.
        if ($organisationId !== null || $existingUser !== null) {

            logActivity(
                'Creatia Button: setup needs a manual decision. '
                . 'Identity ' . $clientId . ' -> organisation ' . ($organisationId ?? 'none') . ', '
                . 'email ' . $email . ' -> organisation ' . ($existingOrg ?? 'none')
                . ' (subtenant ' . ($creatiaUser['subtenant'] ?? 'unknown') . ', this install ' . $subtenant . '). '
                . 'Confirm the organisation belongs to this client before linking it.',
                $clientId
            );

            return $this->page('Already Have Account', 'already-unlinked', $this->reference($client));
        }

        // 7. Nothing exists under either signal - safe to create.
        //
        //    The address is checked first: create_user_v2 applies 'email:rfc,dns', and
        //    discovering that after the organisation exists would leave an empty one
        //    holding this client's identity.
        $domain = substr(strrchr($email, '@') ?: '', 1);

        if ($domain === '' || !checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
            throw new Exception("$email is not an address Creatia will accept (no MX or A record for '$domain')");
        }

        $organisation = $api->createOrganisation(
            $client->companyName != '' ? $client->companyName : $client->fullName,
            [ [ 'key' => $identityKey, 'identity' => (string) $clientId ] ],
            $subtenant
        );

        if (($organisation['detail'] ?? null) === null) {
            throw new Exception('createOrganisation returned no organisation id');
        }

        $this->createUser($api, (string) $organisation['detail'], $client, $readonly);

        return $this->page('Provisioning In Progress', 'success', [
            'billingEmail' => $email,
        ] + $this->reference($client));
    }

    /**
     * @param PhpCreatia $api
     * @param string $organisationId
     * @param object $client WHMCS client
     * @param bool $readonly
     * @return void
     * @throws Exception If the API accepted the request but returned no user id.
     */
    private function createUser($api, string $organisationId, $client, bool $readonly): void {

        $result = $api->createUser($organisationId, (string) $client->email, (string) $client->fullName, $readonly);

        if (($result['detail'] ?? null) === null) {
            throw new Exception("createUser returned no user id for organisation $organisationId");
        }
    }

    /* ------------------------------------------------------------------ *
     * Helpers
     * ------------------------------------------------------------------ */

    /**
     * Why the client's address is or is not usable.
     *
     * The two failures need different things from the customer - one is self service,
     * the other is not - so they are distinguished rather than collapsed into a single
     * "verify your email" message that half of them cannot act on.
     *
     * @param object $client WHMCS client
     * @return string one of the ADDRESS_* constants
     */
    private function addressStatus($client): string {

        if ($this->addressStatus !== null) {
            return $this->addressStatus;
        }

        $owner = Capsule::table('tblusers_clients')
            ->join('tblusers', 'tblusers.id', '=', 'tblusers_clients.auth_user_id')
            ->where('tblusers_clients.client_id', (int) $client->id)
            ->where('tblusers_clients.owner', 1)
            ->first(['tblusers.email', 'tblusers.email_verified_at']);

        if ($owner === null || $owner->email_verified_at === null) {
            return $this->addressStatus = self::ADDRESS_UNVERIFIED;
        }

        if (!$this->sameAddress((string) $owner->email, (string) $client->email)) {

            logActivity(
                'Creatia Button: billing address does not match the verified login address, so setup is blocked. '
                . 'Align the two addresses to release it.',
                (int) $client->id
            );

            return $this->addressStatus = self::ADDRESS_MISMATCH;
        }

        return $this->addressStatus = self::ADDRESS_OK;
    }

    /**
     * The page to show a client whose address is not usable.
     *
     * @param string $status
     * @return array
     */
    private function addressPage(string $status, $client): array {

        if ($status === self::ADDRESS_MISMATCH) {
            return $this->page('Email Address Mismatch', 'verify-mismatch', [
                'billingEmail' => (string) $client->email,
                'loginEmail'   => (string) $this->ownerAddress($client),
            ] + $this->reference($client));
        }

        return $this->page('Email Verification Required', 'verify', [
            'billingEmail' => (string) $client->email,
        ] + $this->reference($client));
    }

    /**
     * Why this client cannot use the module: nothing active, or nothing on the server the
     * administrator nominated. Saying which is the difference between an actionable page
     * and a dead end.
     *
     * @param object|null $client
     * @return array
     */
    private function requiredPage($client): array {

        $hasService = $client !== null && Eligibility::hasAnyActiveService((int) $client->id);

        return $this->page('Service Required', 'required', [
            'hasService' => $hasService,
        ] + ($client === null ? [] : $this->reference($client)));
    }

    /**
     * The owner user's address, for telling the client which two addresses disagree.
     *
     * @param object $client
     * @return string
     */
    private function ownerAddress($client): string {

        $owner = Capsule::table('tblusers_clients')
            ->join('tblusers', 'tblusers.id', '=', 'tblusers_clients.auth_user_id')
            ->where('tblusers_clients.client_id', (int) $client->id)
            ->where('tblusers_clients.owner', 1)
            ->orderBy('tblusers.id', 'asc')
            ->value('tblusers.email');

        return (string) $owner;
    }

    /**
     * Something a customer can quote and support can find.
     *
     * Every page that ends in "contact support" otherwise asks the customer to describe a
     * problem they cannot see, and support to go hunting for the matching activity log
     * entry. The client ID and the time locate it directly.
     *
     * @param object $client
     * @return array
     */
    private function reference($client): array {
        return [
            'supportRef'  => 'CB-' . (int) $client->id,
            'supportTime' => date('j M Y, H:i T'),
        ];
    }

    /**
     * @param string $a
     * @param string $b
     * @return bool
     */
    private function sameAddress(string $a, string $b): bool {
        return strtolower(trim($a)) === strtolower(trim($b));
    }

    /**
     * Whether this request is a form post from this session rather than a link.
     *
     * @return bool
     */
    private function requestIsGenuine(): bool {

        if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
            return false;
        }

        $submitted = $_POST['creatia_token'] ?? '';
        $expected  = $_SESSION['creatia_button_token'] ?? '';

        return is_string($submitted) && $expected !== '' && hash_equals($expected, $submitted);
    }

    /**
     * The token embedded in the setup form, minted once per session.
     *
     * @return string
     */
    private function csrfToken(): string {

        if (empty($_SESSION['creatia_button_token'])) {
            $_SESSION['creatia_button_token'] = bin2hex(random_bytes(32));
        }

        return $_SESSION['creatia_button_token'];
    }

    /**
     * @return object|null
     */
    private function client() {
        return (new \WHMCS\Authentication\CurrentUser)->client();
    }

    /**
     * Read an addon setting, treating an empty value as absent.
     *
     * @param string $name
     * @param string|null $default
     * @return string|null
     */
    private function setting(string $name, ?string $default = null): ?string {

        $value = $this->settings()[$name] ?? null;

        return ($value === null || $value === '') ? $default : $value;
    }

    /**
     * Every setting for this module, read once per request.
     *
     * @return array<string,string>
     */
    private function settings(): array {

        if ($this->settings === null) {
            $this->settings = Eligibility::settings();
        }

        return $this->settings;
    }

    /**
     * @param string $title
     * @param string $template
     * @param array $vars
     * @return array
     */
    private function page(string $title, string $template, array $vars = []): array {
        return [
            'pagetitle'    => $title,
            'breadcrumb'   => [ 'index.php?m=creatia_button&action=index' => 'Creatia Setup' ],
            'templatefile' => $template,
            'requirelogin' => true,
            'vars'         => $vars,
        ];
    }
}
