<?php

namespace App\Modules\Registrar\Opensrs;

use App\Core\Registrar\AbstractRegistrar;
use Illuminate\Support\Facades\Log;

class Opensrs extends AbstractRegistrar
{
    /**
     * Live API endpoint
     */
    protected string $liveApiUrl = 'https://rr-n1-tor.opensrs.net:55443';

    /**
     * Test (sandbox) API endpoint
     */
    protected string $testApiUrl = 'https://horizon.opensrs.net:55443';

    // -------------------------------------------------------------------------
    //  Configuration
    // -------------------------------------------------------------------------

    /**
     * Return configuration fields for the admin settings form.
     */
    public function getConfigFields(): array
    {
        return [
            'username' => [
                'type' => 'text',
                'label' => __('admin/modules.opensrs_field_username_label'),
                'required' => true,
                'description' => __('admin/modules.opensrs_field_username_description'),
                'placeholder' => 'reseller_username',
                'value' => $this->config['username'] ?? '',
            ],
            'private_key' => [
                'type' => 'password',
                'label' => __('admin/modules.opensrs_field_private_key_label'),
                'required' => true,
                'description' => __('admin/modules.opensrs_field_private_key_description'),
                'placeholder' => '********',
                'value' => $this->config['private_key'] ?? '',
            ],
            'test_mode' => [
                'type' => 'checkbox',
                'label' => __('admin/modules.opensrs_field_test_mode_label'),
                'required' => false,
                'description' => __('admin/modules.opensrs_field_test_mode_description'),
                'default' => false,
                'value' => $this->config['test_mode'] ?? false,
            ],
        ];
    }

    /**
     * Validate that the required configuration values are present.
     */
    public function validateConfig(): array
    {
        $errors = [];

        if (empty($this->config['username'])) {
            $errors[] = __('admin/modules.opensrs_error_username_required');
        }

        if (empty($this->config['private_key'])) {
            $errors[] = __('admin/modules.opensrs_error_private_key_required');
        }

        if (! empty($errors)) {
            return [
                'status' => 'error',
                'message' => implode(', ', $errors),
            ];
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_config_valid'),
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Registration
    // -------------------------------------------------------------------------

    /**
     * Register a new domain.
     */
    public function registerDomain(array $params): array
    {
        $requiredParams = ['domain', 'period'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);
        $nameservers = $this->buildNameserverList($params['nameservers'] ?? []);
        $contactSet = $this->buildContactSet($params);
        $whoisPrivacy = ! empty($params['id_protection']) ? '1' : '0';
        $af = is_array($params['additional_fields'] ?? null) ? $params['additional_fields'] : [];

        $attributes = [
            'domain' => $domain,
            'period' => $params['period'],
            'reg_type' => 'new',
            'reg_username' => $this->generateUsername($domain),
            'reg_password' => $this->generatePassword(),
            'f_lock_domain' => '1',
            'custom_tech_contact' => '1',
            'custom_nameservers' => '1',
            // legal_type + isa_trademark are unconditional register attributes;
            // for .uk/.ca they carry the registry-required registrant legal type
            // (mapped to OpenSRS codes), empty for unrestricted TLDs. Ref: WHMCS
            // opensrs.php:125-223,257.
            'legal_type' => $this->buildLegalType($domain, $af),
            'isa_trademark' => $this->af($af, 'Trademark Number', 'trademark_number') !== '' ? '1' : '0',
            'f_whois_privacy' => $whoisPrivacy,
            'link_domains' => '0',
            'lang_pref' => 'EN',
            'nameserver_list' => $nameservers,
            'contact_set' => $contactSet,
        ];

        // TLD-restricted registration data (.us/.it/.fr/.au/.ca/.uk/.pro …).
        // WHMCS builds attributes['tld_data'] from additionalfields per-TLD
        // (opensrs.php:258-392); the PRC-49 builder emits $params['additional_fields']
        // (client_domain_fields, flat key=>value), so map that into the OpenSRS
        // tld_data structure here. A pre-built $params['tld_data'] wins if present.
        $tldData = ! empty($params['tld_data']) && is_array($params['tld_data'])
            ? $params['tld_data']
            : $this->buildTldData($domain, $params);
        if (! empty($tldData)) {
            $attributes['tld_data'] = $tldData;
        }

        $response = $this->sendCommand('SW_REGISTER', 'DOMAIN', $attributes);

        if (! $this->isSuccess($response)) {
            Log::error('OpenSRS: Domain registration failed', [
                'domain' => $domain,
                'error' => $this->getErrorMessage($response),
            ]);

            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        // Process pending order
        $orderId = $response['attributes']['id'] ?? null;
        if ($orderId) {
            $processResult = $this->sendCommand(
                'process_pending',
                'domain',
                $this->buildProcessPendingAttributes($response, $params)
            );

            if (! $this->isSuccess($processResult)) {
                Log::warning('OpenSRS: Process pending failed after registration', [
                    'domain' => $domain,
                    'order_id' => $orderId,
                    'error' => $this->getErrorMessage($processResult),
                ]);
            }
        }

        Log::info('OpenSRS: Domain registered', [
            'domain' => $domain,
            'period' => $params['period'],
            'order_id' => $orderId,
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_register_success'),
            'data' => [
                'domain' => $domain,
                'order_id' => $orderId,
                'period' => $params['period'],
                'registrar' => 'opensrs',
                'registered_at' => now()->toISOString(),
                'expires_at' => now()->addYears((int) $params['period'])->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Transfer
    // -------------------------------------------------------------------------

    /**
     * Transfer a domain to OpenSRS.
     */
    public function transferDomain(array $params): array
    {
        $requiredParams = ['domain'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);
        $period = $params['period'] ?? 1;
        $nameservers = $this->buildNameserverList($params['nameservers'] ?? []);
        $contactSet = $this->buildContactSet($params);
        $whoisPrivacy = ! empty($params['id_protection']) ? '1' : '0';

        $attributes = [
            'domain' => $domain,
            'period' => $period,
            'reg_type' => 'transfer',
            'reg_username' => $this->generateUsername($domain),
            'reg_password' => $this->generatePassword(),
            'f_lock_domain' => '1',
            'custom_tech_contact' => '0',
            'custom_nameservers' => '1',
            'f_whois_privacy' => $whoisPrivacy,
            'link_domains' => '0',
            'nameserver_list' => $nameservers,
            'contact_set' => $contactSet,
        ];

        if (! empty($params['epp_code'])) {
            $attributes['domain_auth_info'] = $params['epp_code'];
        }

        // IRTP / registrant-confirm ccTLDs (.au/.de/.be/.eu/.it) require an
        // owner_confirm_address so OpenSRS can e-mail the approval request to the
        // registrant. WHMCS opensrs.php:460-462. Source the owner e-mail from the
        // canonical registrant contact.
        if ($this->tldRequiresOwnerConfirm($domain)) {
            $ownerEmail = $this->contact($params, 'registrant')['email'] ?? '';
            if ($ownerEmail !== '') {
                $attributes['owner_confirm_address'] = $ownerEmail;
            }
        }

        // TLD-restricted transfer data (same map as register).
        $tldData = ! empty($params['tld_data']) && is_array($params['tld_data'])
            ? $params['tld_data']
            : $this->buildTldData($domain, $params);
        if (! empty($tldData)) {
            $attributes['tld_data'] = $tldData;
        }

        $response = $this->sendCommand('SW_REGISTER', 'DOMAIN', $attributes);

        if (! $this->isSuccess($response)) {
            Log::error('OpenSRS: Domain transfer failed', [
                'domain' => $domain,
                'error' => $this->getErrorMessage($response),
            ]);

            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        // Process pending transfer order
        $orderId = $response['attributes']['id'] ?? null;
        if ($orderId) {
            $this->sendCommand(
                'process_pending',
                'domain',
                $this->buildProcessPendingAttributes($response, $params)
            );
        }

        Log::info('OpenSRS: Domain transfer initiated', [
            'domain' => $domain,
            'order_id' => $orderId,
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_transfer_success'),
            'data' => [
                'domain' => $domain,
                'order_id' => $orderId,
                'period' => $period,
                'registrar' => 'opensrs',
                'transfer_started_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Renewal
    // -------------------------------------------------------------------------

    /**
     * Renew a domain.
     */
    public function renewDomain(array $params): array
    {
        $requiredParams = ['domain', 'period'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);

        // OpenSRS requires `currentexpirationyear` to equal the YEAR of the
        // domain's CURRENT registry expiry date — a renew whose year does not
        // match the actual expiry is rejected. WHMCS derives it via
        // substr(expirydate,0,4) from tbldomains (opensrs.php:489-492). The
        // module never queries the DB (PRC-49) so the caller MUST supply the
        // real current-expiry year; NO date('Y') calendar-year fallback.
        $expiryYear = $this->resolveCurrentExpiryYear($params);
        if ($expiryYear === null) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_expiry_year_required'),
            ];
        }

        $attributes = [
            'domain' => $domain,
            'period' => $params['period'],
            'currentexpirationyear' => $expiryYear,
            'handle' => 'process',
            'auto_renew' => '0',
        ];

        $response = $this->sendCommand('renew', 'DOMAIN', $attributes);

        if (! $this->isSuccess($response)) {
            Log::error('OpenSRS: Domain renewal failed', [
                'domain' => $domain,
                'error' => $this->getErrorMessage($response),
            ]);

            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        // OpenSRS returns the new expiry under the space-separated key
        // 'registration expiration date' (dt_assoc keys preserve spaces);
        // accept the underscored variant too for forward-compat.
        $respAttrs = $response['attributes'] ?? [];
        $newExpiryDate = $respAttrs['registration expiration date']
            ?? $respAttrs['registration_expiration_date']
            ?? $respAttrs['expiredate']
            ?? null;

        Log::info('OpenSRS: Domain renewed', [
            'domain' => $domain,
            'period' => $params['period'],
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_renew_success'),
            'data' => [
                'domain' => $domain,
                'period' => $params['period'],
                'registrar' => 'opensrs',
                'renewed_at' => now()->toISOString(),
                'expires_at' => $newExpiryDate,
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Nameservers
    // -------------------------------------------------------------------------

    /**
     * Get nameservers for a domain.
     */
    public function getNameservers(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'nameservers',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $nameservers = [];
        $nsList = $response['attributes']['nameserver_list'] ?? [];
        foreach ($nsList as $ns) {
            if (! empty($ns['name'])) {
                $nameservers[] = $ns['name'];
            }
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_nameservers_success'),
            'data' => [
                'domain' => $domain,
                'nameservers' => $nameservers,
            ],
        ];
    }

    /**
     * Set nameservers for a domain.
     */
    public function setNameservers(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        if (empty($params['nameservers']) || ! is_array($params['nameservers'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_nameservers_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('advanced_update_nameservers', 'domain', [
            'domain' => $domain,
            'op_type' => 'assign',
            'assign_ns' => array_values($params['nameservers']),
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Nameservers updated', [
            'domain' => $domain,
            'nameservers' => $params['nameservers'],
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_set_nameservers_success'),
            'data' => [
                'domain' => $domain,
                'nameservers' => $params['nameservers'],
                'updated_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Contact Details
    // -------------------------------------------------------------------------

    /**
     * Get contact details for a domain.
     */
    public function getContactDetails(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('GET_DOMAINS_CONTACTS', 'DOMAIN', [
            'domain_list' => [$domain],
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $contactSet = $response['attributes'][$domain]['contact_set'] ?? [];

        $groups = [];
        $contactMap = [
            'owner' => 'Owner',
            'admin' => 'Admin',
            'billing' => 'Billing',
            'tech' => 'Technical',
        ];

        foreach ($contactMap as $apiKey => $groupLabel) {
            $data = $contactSet[$apiKey] ?? [];
            if (! empty($data)) {
                $groups[$groupLabel] = [
                    'firstname' => $data['first_name'] ?? '',
                    'lastname' => $data['last_name'] ?? '',
                    'company' => $data['org_name'] ?? '',
                    'email' => $data['email'] ?? '',
                    'address' => $data['address1'] ?? '',
                    'address2' => $data['address2'] ?? '',
                    'city' => $data['city'] ?? '',
                    'state' => $data['state'] ?? '',
                    'postal_code' => $data['postal_code'] ?? '',
                    'country' => $data['country'] ?? '',
                    'phone' => $data['phone'] ?? '',
                    'fax' => $data['fax'] ?? '',
                ];
            }
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_contacts_success'),
            'groups' => $groups,
        ];
    }

    /**
     * Set contact details for a domain.
     */
    public function setContactDetails(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        if (empty($params['groups']) || ! is_array($params['groups'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_contacts_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $contactGroupMap = [
            'Owner' => 'owner',
            'Admin' => 'admin',
            'Billing' => 'billing',
            'Technical' => 'tech',
        ];

        $isCa = strtolower($this->extractTld($domain)) === 'ca';

        $contactSet = [];
        $ownerEmail = '';
        foreach ($params['groups'] as $groupLabel => $fields) {
            $apiKey = $contactGroupMap[$groupLabel] ?? null;
            if (! $apiKey) {
                continue;
            }

            $company = trim((string) ($fields['company'] ?? ''));
            $country = (string) ($fields['country'] ?? '');

            // WHMCS runs convertStateToCode() on every state and convertToCiraCode()
            // for .ca (opensrs.php:606,610-612): registries that require ISO / CIRA
            // state codes reject full state names. Apply the same normalisation.
            $state = $this->normalizeState((string) ($fields['state'] ?? ''), $country);
            if ($isCa) {
                $state = $this->convertToCiraCode($state);
            }

            $email = (string) ($fields['email'] ?? '');
            if ($apiKey === 'owner') {
                $ownerEmail = $email;
            }

            $contactSet[$apiKey] = [
                'first_name' => $fields['firstname'] ?? '',
                'last_name' => $fields['lastname'] ?? '',
                'org_name' => $company !== '' ? $company : 'None',
                'email' => $email,
                'address1' => $fields['address'] ?? '',
                'address2' => $fields['address2'] ?? '',
                'address3' => '',
                'city' => $fields['city'] ?? '',
                'state' => $state,
                'postal_code' => $fields['postal_code'] ?? '',
                'country' => $country,
                // WHMCS getFormattedPhoneNumber applies +CC. formatting to every
                // contact phone on modify too (opensrs.php:606).
                'phone' => $this->formatPhone((string) ($fields['phone'] ?? ''), $country),
                'fax' => $fields['fax'] ?? '',
                'lang_pref' => 'EN',
            ];
        }

        $modifyAttributes = [
            'domain' => $domain,
            'data' => 'contact_info',
            'affect_domains' => '0',
            'lang_pref' => 'EN',
            'contact_set' => $contactSet,
        ];

        // report_email => Owner email at the top level of attributes so OpenSRS
        // sends the contact-change notification to the registrant (WHMCS
        // opensrs.php:606).
        if ($ownerEmail !== '') {
            $modifyAttributes['report_email'] = $ownerEmail;
        }

        $response = $this->sendCommand('modify', 'domain', $modifyAttributes);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Contact details updated', ['domain' => $domain]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_set_contacts_success'),
            'data' => [
                'domain' => $domain,
                'groups' => $params['groups'],
                'updated_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Lock
    // -------------------------------------------------------------------------

    /**
     * Get the lock status for a domain.
     */
    public function getDomainLock(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'status',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $lockState = ($response['attributes']['lock_state'] ?? '0') === '1';

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_lock_success'),
            'data' => [
                'domain' => $domain,
                'locked' => $lockState,
            ],
        ];
    }

    /**
     * Update (set or remove) the lock on a domain.
     */
    public function updateDomainLock(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);
        $lockState = ! empty($params['lock_status']) ? '1' : '0';

        $response = $this->sendCommand('modify', 'domain', [
            'domain' => $domain,
            'data' => 'status',
            'lock_state' => $lockState,
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Domain lock updated', [
            'domain' => $domain,
            'locked' => $lockState === '1',
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_set_lock_success'),
            'data' => [
                'domain' => $domain,
                'locked' => $lockState === '1',
                'updated_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  EPP Code
    // -------------------------------------------------------------------------

    /**
     * Get the EPP / auth-info code for a domain.
     */
    public function getEppCode(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'domain_auth_info',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $eppCode = $response['attributes']['domain_auth_info'] ?? '';

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_epp_success'),
            'data' => [
                'domain' => $domain,
                'epp_code' => $eppCode,
            ],
        ];
    }

    /**
     * Request OpenSRS to e-mail the EPP code to the registrant.
     */
    public function sendEppCode(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('send_authcode', 'domain', [
            'domain_name' => $domain,
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: EPP code sent', ['domain' => $domain]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_send_epp_success'),
            'data' => [
                'domain' => $domain,
                'sent_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Availability Check
    // -------------------------------------------------------------------------

    /**
     * Check whether a domain name is available for registration.
     */
    public function checkDomainAvailability(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('lookup', 'domain', [
            'domain' => $domain,
            'no_cache' => '1',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $status = $response['attributes']['status'] ?? '';
        $isAvailable = stripos($status, 'available') !== false;

        $data = [
            'domain' => $domain,
            'available' => $isAvailable,
            'checked_at' => now()->toISOString(),
        ];

        // PRC-295 Phase A — OpenSRS lookup may return `premium` attribute
        // (string 'true' or 'false') and a `price` element when the domain
        // is on the premium register. Premium pricing is the one-time
        // registration fee only; renewal uses standard TLD pricing.
        //
        // On a CLASSIC lookup the premium/price fields are absent and premium
        // status is signalled via attributes['reason'] == 'Premium Name'
        // (openSRS_base.php:516; domaintescil libraries/Opensrs/opensrs.php:64).
        // A reserved name is signalled via reason == 'Reserved Name'.
        $attrs = $response['attributes'] ?? [];
        $reason = (string) ($attrs['reason'] ?? '');
        $isPremium = (string) ($attrs['premium'] ?? '') === 'true'
            || strcasecmp($reason, 'Premium Name') === 0;

        if (strcasecmp($reason, 'Reserved Name') === 0) {
            $data['available'] = false;
            $data['reserved'] = true;
        }

        if ($isPremium) {
            $price = $attrs['price'] ?? null;
            $registerPrice = ($price !== null && $price !== '')
                ? (float) $price
                : null;

            $data['premium'] = true;
            $data['premium_register_price'] = $registerPrice;
            $data['premium_currency'] = (string) ($attrs['currency'] ?? 'USD');
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_check_availability_success'),
            'data' => $data,
        ];
    }

    /**
     * Return domain-name suggestions for a search term (OpenSRS name_suggest).
     *
     * Request: action=name_suggest / object=domain, attributes{searchstring,
     * tlds:[...], services:['lookup','suggestion','premium','personal_names']}.
     * Response: attributes[<service>]['items'][*]{domain,status,price}.
     * Ref: OpenSRS name_suggest (domains.opensrs.guide/docs/name_suggest).
     */
    public function checkDomainSuggestions(array $params): array
    {
        $searchString = trim((string) ($params['searchTerm']
            ?? $params['searchstring']
            ?? $params['keyword']
            ?? $params['domain']
            ?? ''));

        if ($searchString === '') {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_search_required'),
            ];
        }

        // Normalise the requested TLDs to the dotted OpenSRS form ('.com').
        $tlds = $this->normalizeSuggestionTlds($params['tlds'] ?? $params['tldList'] ?? []);
        if (empty($tlds)) {
            $tlds = ['.com', '.net', '.org'];
        }

        // OpenSRS name_suggest scopes the requested TLDs per-service via
        // service_override.{lookup,suggestion}.tlds — the proven-working shape,
        // NOT a flat top-level `tlds`. Ref: domaintescil lookupNameSuggest.php:99-113.
        $tldList = array_values($tlds);
        $attributes = [
            'searchstring' => $searchString,
            'service_override' => [
                'lookup' => ['tlds' => $tldList],
                'suggestion' => ['tlds' => $tldList],
            ],
            'services' => ['lookup', 'suggestion'],
        ];

        $response = $this->sendCommand('name_suggest', 'domain', $attributes);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $attrs = $response['attributes'] ?? [];
        $suggestions = [];

        // Merge items across the returned services (lookup + suggestion), keyed
        // by domain so exact-match lookups and generated suggestions don't dup.
        foreach (['lookup', 'suggestion', 'premium', 'personal_names'] as $service) {
            $items = $attrs[$service]['items'] ?? [];
            if (! is_array($items)) {
                continue;
            }

            foreach ($items as $item) {
                $name = strtolower((string) ($item['domain'] ?? ''));
                if ($name === '') {
                    continue;
                }

                $status = strtolower((string) ($item['status'] ?? ''));
                $price = $item['price'] ?? null;

                $suggestions[$name] = [
                    'domain' => $name,
                    'available' => $status === 'available',
                    'status' => $status,
                    'price' => ($price !== null && $price !== '') ? (float) $price : null,
                    'premium' => $service === 'premium',
                ];
            }
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_suggestions_success'),
            'data' => [
                'searchterm' => $searchString,
                'suggestions' => array_values($suggestions),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Privacy (WHOIS ID Protection)
    // -------------------------------------------------------------------------

    /**
     * Enable / disable WHOIS privacy (ID protection) for a domain.
     *
     * Request: action=modify / object=domain, attributes{domain, domain_name,
     * affect_domains=0, data=whois_privacy_state, state=enable|disable}.
     * Ref (proven): domaintescil opensrspro_IDProtectToggle (opensrspro.php:97-108).
     * The privacy state comes from $params['id_protection'] (truthy = enable).
     */
    public function updatePrivacySettings(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        // Accept id_protection / enabled / enable / state as the enable flag.
        $enabled = $this->resolvePrivacyEnabled($params);
        $state = $enabled ? 'enable' : 'disable';

        $response = $this->sendCommand('modify', 'domain', [
            'domain' => $domain,
            'domain_name' => $domain,
            'affect_domains' => '0',
            'data' => 'whois_privacy_state',
            'state' => $state,
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Privacy settings updated', [
            'domain' => $domain,
            'enabled' => $enabled,
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_privacy_success'),
            'data' => [
                'domain' => $domain,
                'id_protection' => $enabled,
                'updated_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Details
    // -------------------------------------------------------------------------

    /**
     * Get full domain information from OpenSRS.
     */
    public function getDomainDetails(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'all_info',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $attrs = $response['attributes'] ?? [];

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_domain_details_success'),
            'data' => [
                'domain' => $domain,
                // Map the real OpenSRS domain status/state — NOT sponsoring_rsp
                // (that is the sponsoring reseller). Normalised to the contract
                // vocabulary active|pending|suspended|expired.
                'status' => $this->normalizeDomainStatus($attrs),
                // Sponsoring reseller preserved as a distinct data field.
                'sponsoring_rsp' => $attrs['sponsoring_rsp'] ?? null,
                'locked' => ($attrs['lock_state'] ?? '0') === '1',
                'auto_renew' => ($attrs['auto_renew'] ?? '0') === '1',
                'created_at' => $attrs['registry_createdate'] ?? null,
                'updated_at' => $attrs['registry_updatedate'] ?? null,
                'expires_at' => $attrs['expiredate'] ?? null,
                'registrar' => 'OpenSRS',
            ],
        ];
    }

    /**
     * Normalise the OpenSRS `all_info` status/state into the registrar contract
     * vocabulary: active|pending|suspended|expired (falls back to 'active' when
     * OpenSRS reports no explicit lifecycle state, matching WHMCS which treats a
     * successful all_info as active).
     */
    protected function normalizeDomainStatus(array $attrs): string
    {
        $raw = strtolower(trim((string) ($attrs['status'] ?? $attrs['state'] ?? '')));

        if ($raw === '') {
            return 'active';
        }

        return match (true) {
            str_contains($raw, 'expire') => 'expired',
            str_contains($raw, 'pending'),
            str_contains($raw, 'transfer'),
            str_contains($raw, 'wait') => 'pending',
            str_contains($raw, 'suspend'),
            str_contains($raw, 'hold'),
            str_contains($raw, 'lock') => 'suspended',
            str_contains($raw, 'active'),
            str_contains($raw, 'ok'),
            str_contains($raw, 'registered') => 'active',
            default => 'active',
        };
    }

    // -------------------------------------------------------------------------
    //  Child Nameservers (Glue Records)
    // -------------------------------------------------------------------------

    /**
     * Get child nameservers for a domain.
     */
    public function getChildNameservers(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        // Authoritative glue/IP data comes from the get/object=nameserver view
        // with name='all' (returns the registered child nameservers for the
        // domain incl. ipaddress/ipv6/can_delete), NOT the get/object=domain
        // type=nameservers *delegation* view which omits IPs. The domain that
        // scopes the lookup is a TOP-LEVEL command key ($cmd['domain']), a
        // sibling of attributes — NOT inside attributes. Ref: domaintescil
        // nsGet.php:56-69 (attributes carries only name='all').
        $response = $this->sendCommand('get', 'nameserver', ['name' => 'all'], ['domain' => $domain]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $nameservers = [];
        $nsList = $response['attributes']['nameserver_list'] ?? [];
        foreach ($nsList as $index => $ns) {
            if (empty($ns['name'])) {
                continue;
            }
            $nameservers[] = [
                'id' => (string) ($index + 1),
                'hostname' => $ns['name'],
                'ip' => $ns['ipaddress'] ?? '',
                'ipv6' => $ns['ipv6'] ?? '',
            ];
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_get_child_ns_success'),
            'data' => [
                'domain' => $domain,
                'nameservers' => $nameservers,
            ],
        ];
    }

    /**
     * Create a child nameserver (glue record).
     */
    public function addChildNameserver(array $params): array
    {
        $requiredParams = ['domain', 'hostname', 'ip'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('create', 'nameserver', [
            'domain' => $domain,
            'name' => $params['hostname'],
            'ipaddress' => $params['ip'],
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Child nameserver created', [
            'domain' => $domain,
            'hostname' => $params['hostname'],
            'ip' => $params['ip'],
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_add_child_ns_success'),
            'data' => [
                'hostname' => $params['hostname'],
                'ip' => $params['ip'],
            ],
        ];
    }

    /**
     * Update a child nameserver (glue record) IP address.
     */
    public function updateChildNameserver(array $params): array
    {
        $requiredParams = ['domain', 'hostname', 'ip'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('modify', 'nameserver', [
            'domain' => $domain,
            'name' => $params['hostname'],
            'new_name' => $params['hostname'],
            'ipaddress' => $params['ip'],
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Child nameserver updated', [
            'domain' => $domain,
            'hostname' => $params['hostname'],
            'ip' => $params['ip'],
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_update_child_ns_success'),
            'data' => [
                'hostname' => $params['hostname'],
                'ip' => $params['ip'],
            ],
        ];
    }

    /**
     * Delete a child nameserver (glue record).
     */
    public function deleteChildNameserver(array $params): array
    {
        $requiredParams = ['domain', 'hostname'];

        foreach ($requiredParams as $param) {
            if (empty($params[$param])) {
                return [
                    'status' => 'error',
                    'message' => __('admin/modules.opensrs_error_missing_param', ['param' => $param]),
                ];
            }
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('delete', 'nameserver', [
            'domain' => $domain,
            'name' => $params['hostname'],
            'ipaddress' => $params['ip'] ?? '',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        Log::info('OpenSRS: Child nameserver deleted', [
            'domain' => $domain,
            'hostname' => $params['hostname'],
        ]);

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_delete_child_ns_success'),
            'data' => [
                'hostname' => $params['hostname'],
                'deleted_at' => now()->toISOString(),
            ],
        ];
    }

    // -------------------------------------------------------------------------
    //  Domain Sync
    // -------------------------------------------------------------------------

    /**
     * Synchronise domain status and expiry date from OpenSRS.
     */
    public function syncDomain(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'all_info',
        ]);

        if (! $this->isSuccess($response)) {
            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $attrs = $response['attributes'] ?? [];
        $expiryDate = $attrs['expiredate'] ?? null;

        // OpenSRS returns "2025-06-15 12:00:00" – extract date part
        if ($expiryDate && str_contains($expiryDate, ' ')) {
            $expiryDate = explode(' ', $expiryDate)[0];
        }

        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_sync_success'),
            'data' => [
                'domain' => $domain,
                'status' => 'active',
                'expires_at' => $expiryDate,
                'auto_renew' => ($attrs['auto_renew'] ?? '0') === '1',
                'locked' => ($attrs['lock_state'] ?? '0') === '1',
                'synced_at' => now()->toISOString(),
            ],
        ];
    }

    /**
     * Synchronise domain transfer status from OpenSRS.
     *
     * Uses the authoritative `CHECK_TRANSFER` command (object=DOMAIN,
     * attributes{domain, check_status=1, get_request_address=1}) whose
     * attributes.status is one of completed|cancelled|pending_owner|
     * pending_registry — NOT the all_info-success=completed assumption which
     * cannot distinguish an in-flight transfer from a completed one.
     * Ref (proven): domaintescil opensrspro_TransferSync (opensrspro.php:2504-2585).
     * On `completed` a follow-up get/all_info fetches the new expiry date.
     */
    public function syncDomainTransfer(array $params): array
    {
        if (empty($params['domain'])) {
            return [
                'status' => 'error',
                'message' => __('admin/modules.opensrs_error_domain_required'),
            ];
        }

        $domain = strtolower($params['domain']);

        $response = $this->sendCommand('CHECK_TRANSFER', 'DOMAIN', [
            'domain' => $domain,
            'check_status' => '1',
            'get_request_address' => '1',
        ]);

        if (! $this->isSuccess($response)) {
            // A genuine "domain not yet in the reseller account" failure means
            // the transfer is still pending. Other API failures (bad
            // credentials, suspended reseller, connection errors) must surface
            // as errors rather than silently report an in-progress transfer.
            if ($this->isDomainNotFound($response)) {
                return [
                    'status' => 'success',
                    'message' => __('admin/modules.opensrs_transfer_in_progress'),
                    'data' => [
                        'domain' => $domain,
                        'completed' => false,
                        'synced_at' => now()->toISOString(),
                    ],
                ];
            }

            return [
                'status' => 'error',
                'message' => $this->getErrorMessage($response),
            ];
        }

        $attrs = $response['attributes'] ?? [];
        $transferStatus = strtolower((string) ($attrs['status'] ?? ''));
        $requestAddress = $attrs['request_address'] ?? null;

        // Transfer completed → fetch the new expiry via all_info.
        if ($transferStatus === 'completed') {
            $expiryDate = $this->fetchTransferExpiryDate($domain);

            return [
                'status' => 'success',
                'message' => __('admin/modules.opensrs_transfer_sync_success'),
                'data' => [
                    'domain' => $domain,
                    'completed' => true,
                    'status' => 'active',
                    'expires_at' => $expiryDate,
                    'synced_at' => now()->toISOString(),
                ],
            ];
        }

        // Transfer cancelled/rejected by the registry or losing registrar.
        if ($transferStatus === 'cancelled') {
            return [
                'status' => 'success',
                'message' => __('admin/modules.opensrs_transfer_failed'),
                'data' => [
                    'domain' => $domain,
                    'completed' => false,
                    'failed' => true,
                    'transfer_status' => $transferStatus,
                    'synced_at' => now()->toISOString(),
                ],
            ];
        }

        // pending_owner | pending_registry | anything else → still in progress.
        return [
            'status' => 'success',
            'message' => __('admin/modules.opensrs_transfer_in_progress'),
            'data' => [
                'domain' => $domain,
                'completed' => false,
                'transfer_status' => $transferStatus,
                'approval_email' => $requestAddress,
                'synced_at' => now()->toISOString(),
            ],
        ];
    }

    /**
     * Fetch the post-transfer expiry date (date-part only) via get/all_info.
     * Returns null when the follow-up call fails.
     */
    protected function fetchTransferExpiryDate(string $domain): ?string
    {
        $response = $this->sendCommand('get', 'domain', [
            'domain' => $domain,
            'type' => 'all_info',
        ]);

        if (! $this->isSuccess($response)) {
            return null;
        }

        $expiryDate = $response['attributes']['expiredate'] ?? null;

        if ($expiryDate && str_contains($expiryDate, ' ')) {
            $expiryDate = explode(' ', $expiryDate)[0];
        }

        return $expiryDate;
    }

    // =========================================================================
    //  PRIVATE / HELPER METHODS
    // =========================================================================

    /**
     * Return the API endpoint URL based on test_mode config.
     */
    protected function getApiUrl(): string
    {
        return ! empty($this->config['test_mode']) ? $this->testApiUrl : $this->liveApiUrl;
    }

    /**
     * Build the MD5 signature required by the OpenSRS XML API.
     *
     * Signature = md5( md5(xml_body + private_key) + private_key )
     */
    protected function buildSignature(string $xmlBody): string
    {
        $privateKey = $this->config['private_key'] ?? '';

        return md5(md5($xmlBody . $privateKey) . $privateKey);
    }

    /**
     * Send an XCP command to the OpenSRS XML API and return the parsed response.
     */
    protected function sendCommand(string $action, string $object, array $attributes = [], array $topLevel = []): array
    {
        $xmlBody = $this->buildXmlRequest($action, $object, $attributes, $topLevel);
        $signature = $this->buildSignature($xmlBody);
        $username = $this->config['username'] ?? '';

        try {
            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $this->getApiUrl(),
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => $xmlBody,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: text/xml',
                    'X-Username: ' . $username,
                    'X-Signature: ' . $signature,
                ],
                CURLOPT_TIMEOUT => 30,
                CURLOPT_CONNECTTIMEOUT => 15,
                CURLOPT_SSL_VERIFYPEER => true,
            ]);

            $rawResponse = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $curlError = curl_error($ch);
            curl_close($ch);

            if ($curlError) {
                Log::error('OpenSRS: cURL error', [
                    'action' => $action,
                    'object' => $object,
                    'error' => $curlError,
                ]);

                return [
                    'is_success' => '0',
                    'response_text' => __('admin/modules.opensrs_error_connection_failed') . ': ' . $curlError,
                ];
            }

            if ($httpCode !== 200) {
                Log::error('OpenSRS: HTTP error', [
                    'action' => $action,
                    'object' => $object,
                    'http_code' => $httpCode,
                ]);

                return [
                    'is_success' => '0',
                    'response_text' => __('admin/modules.opensrs_error_http', ['code' => $httpCode]),
                ];
            }

            $parsed = $this->parseXmlResponse($rawResponse);

            Log::debug('OpenSRS: API call', [
                'action' => $action,
                'object' => $object,
                'success' => $this->isSuccess($parsed),
            ]);

            return $parsed;
        } catch (\Exception $e) {
            Log::error('OpenSRS: Exception during API call', [
                'action' => $action,
                'object' => $object,
                'exception' => $e->getMessage(),
            ]);

            return [
                'is_success' => '0',
                'response_text' => $e->getMessage(),
            ];
        }
    }

    // -------------------------------------------------------------------------
    //  XML Building
    // -------------------------------------------------------------------------

    /**
     * Build the OpenSRS XCP XML request envelope.
     */
    protected function buildXmlRequest(string $action, string $object, array $attributes, array $topLevel = []): string
    {
        $attributesXml = $this->arrayToXmlDataBlock($attributes);

        // registrant_ip is a top-level command key (sibling of action/object/
        // attributes), used by OpenSRS for lookup throttling / fraud scoring.
        // Ref: WHMCS opensrs.php:29,396; openSRS_base.php:237. Server IP first,
        // client IP as fallback.
        $registrantIp = $this->getRegistrantIp();
        $registrantIpXml = $registrantIp !== ''
            ? '<item key="registrant_ip">' . htmlspecialchars($registrantIp) . '</item>'
            : '';

        // Additional top-level command keys (siblings of attributes) — e.g. the
        // `domain` bypass used by get/object=nameserver to scope the lookup.
        // Ref: domaintescil nsGet.php:69 ($cmd['domain'], NOT inside attributes).
        $topLevelXml = '';
        foreach ($topLevel as $key => $value) {
            if ($value === '' || $value === null) {
                continue;
            }
            $topLevelXml .= '<item key="' . htmlspecialchars((string) $key) . '">'
                . (is_array($value) ? $this->arrayToXmlDataBlock($value) : htmlspecialchars((string) $value))
                . '</item>';
        }

        $xml = '<?xml version=\'1.0\' encoding=\'UTF-8\' standalone=\'no\'?>'
            . '<!DOCTYPE OPS_envelope SYSTEM \'ops.dtd\'>'
            . '<OPS_envelope>'
            . '<header><version>0.9</version></header>'
            . '<body>'
            . '<data_block>'
            . '<dt_assoc>'
            . '<item key="protocol">XCP</item>'
            . '<item key="action">' . htmlspecialchars($action) . '</item>'
            . '<item key="object">' . htmlspecialchars($object) . '</item>'
            . $registrantIpXml
            . $topLevelXml
            . '<item key="attributes">' . $attributesXml . '</item>'
            . '</dt_assoc>'
            . '</data_block>'
            . '</body>'
            . '</OPS_envelope>';

        return $xml;
    }

    /**
     * Resolve the registrant_ip value for the OPS envelope.
     *
     * Prefers the server IP (SERVER_ADDR / LOCAL_ADDR — matching the WHMCS
     * reference which uses $server_ip) and falls back to the client IP.
     */
    protected function getRegistrantIp(): string
    {
        return (string) (
            $_SERVER['SERVER_ADDR']
            ?? $_SERVER['LOCAL_ADDR']
            ?? $_SERVER['REMOTE_ADDR']
            ?? ''
        );
    }

    /**
     * Recursively convert a PHP array to an OpenSRS XML data-block string.
     *
     * Associative arrays become <dt_assoc>, sequential arrays become <dt_array>.
     */
    protected function arrayToXmlDataBlock(array $data): string
    {
        if ($this->isAssoc($data)) {
            $xml = '<dt_assoc>';
            foreach ($data as $key => $value) {
                $xml .= '<item key="' . htmlspecialchars((string) $key) . '">';
                if (is_array($value)) {
                    $xml .= $this->arrayToXmlDataBlock($value);
                } else {
                    $xml .= htmlspecialchars((string) $value);
                }
                $xml .= '</item>';
            }
            $xml .= '</dt_assoc>';
        } else {
            $xml = '<dt_array>';
            foreach ($data as $index => $value) {
                $xml .= '<item key="' . $index . '">';
                if (is_array($value)) {
                    $xml .= $this->arrayToXmlDataBlock($value);
                } else {
                    $xml .= htmlspecialchars((string) $value);
                }
                $xml .= '</item>';
            }
            $xml .= '</dt_array>';
        }

        return $xml;
    }

    // -------------------------------------------------------------------------
    //  XML Parsing
    // -------------------------------------------------------------------------

    /**
     * Parse an OpenSRS XML response into a PHP array.
     */
    protected function parseXmlResponse(string $xml): array
    {
        if (empty($xml)) {
            return [
                'is_success' => '0',
                'response_text' => __('admin/modules.opensrs_error_empty_response'),
            ];
        }

        libxml_use_internal_errors(true);
        $doc = simplexml_load_string($xml);

        if ($doc === false) {
            $errors = libxml_get_errors();
            libxml_clear_errors();

            $errorMsg = ! empty($errors) ? $errors[0]->message : 'Unknown XML parse error';
            Log::error('OpenSRS: XML parse error', ['error' => $errorMsg]);

            return [
                'is_success' => '0',
                'response_text' => __('admin/modules.opensrs_error_xml_parse'),
            ];
        }

        // Navigate to the data_block inside <body>
        $dataBlock = $doc->body->data_block ?? null;
        if ($dataBlock === null) {
            return [
                'is_success' => '0',
                'response_text' => __('admin/modules.opensrs_error_invalid_response'),
            ];
        }

        return $this->parseDataBlock($dataBlock);
    }

    /**
     * Recursively parse an OpenSRS <dt_assoc> or <dt_array> node into a PHP array.
     */
    protected function parseDataBlock(\SimpleXMLElement $element): array
    {
        $result = [];

        // Check for dt_assoc or dt_array within the current element
        $dtAssoc = $element->dt_assoc ?? null;
        $dtArray = $element->dt_array ?? null;

        $node = $dtAssoc ?? $dtArray;
        if ($node === null) {
            return $result;
        }

        foreach ($node->item as $item) {
            $key = (string) $item['key'];
            $children = $item->children();

            // Check if this item contains a nested dt_assoc or dt_array
            $hasNestedAssoc = isset($item->dt_assoc);
            $hasNestedArray = isset($item->dt_array);

            if ($hasNestedAssoc || $hasNestedArray) {
                $result[$key] = $this->parseDataBlock($item);
            } else {
                $result[$key] = trim((string) $item);
            }
        }

        return $result;
    }

    // -------------------------------------------------------------------------
    //  Utility Helpers
    // -------------------------------------------------------------------------

    /**
     * Determine whether an API response indicates success.
     */
    protected function isSuccess(array $response): bool
    {
        return ($response['is_success'] ?? '0') === '1'
            || ($response['is_success'] ?? 0) === 1;
    }

    /**
     * Extract a human-readable error message from an API response.
     */
    protected function getErrorMessage(array $response): string
    {
        $text = $response['response_text'] ?? '';
        $detail = $response['attributes']['error'] ?? '';

        if ($text && $detail) {
            return $text . ' - ' . $detail;
        }

        return $text ?: $detail ?: __('admin/modules.opensrs_error_unknown');
    }

    /**
     * Build the OpenSRS nameserver_list array for registration/transfer.
     */
    protected function buildNameserverList(array $nameservers): array
    {
        $list = [];
        foreach (array_values($nameservers) as $index => $ns) {
            if (! empty($ns)) {
                $list[] = [
                    'sortorder' => (string) ($index + 1),
                    'name' => $ns,
                ];
            }
        }

        return $list;
    }

    /**
     * Build the `process_pending` attributes for a completed register/transfer
     * order. OpenSRS requires BOTH `order_id` and `owner_address` (the owner /
     * admin e-mail) to finalise the pending order — sending only `order_id`
     * leaves the order stuck. Ref: WHMCS opensrs.php:407,473.
     *
     * @param  array  $response  The SW_REGISTER response (holds attributes.id + admin_email)
     * @param  array  $params    The original module params (contact fallback source)
     */
    protected function buildProcessPendingAttributes(array $response, array $params): array
    {
        $attrs = $response['attributes'] ?? [];

        $ownerEmail = $attrs['admin_email']
            ?? $attrs['owner_email']
            ?? $this->contact($params, 'admin')['email']
            ?? $this->contact($params, 'registrant')['email']
            ?? '';

        return [
            'owner_address' => $ownerEmail,
            'order_id' => $attrs['id'] ?? null,
        ];
    }

    /**
     * OpenSRS contact_set group name → canonical AbstractRegistrar contact group.
     *
     * OpenSRS uses owner/admin/billing/tech; the PRC-969 contact foundation
     * (`AbstractRegistrar::contact()`) uses registrant/admin/billing/tech.
     */
    protected const CONTACT_GROUP_MAP = [
        'owner' => 'registrant',
        'admin' => 'admin',
        'billing' => 'billing',
        'tech' => 'tech',
    ];

    /**
     * Build the contact_set structure from the PRC-969 canonical contact groups.
     *
     * The builder always emits populated `contacts.{registrant,admin,tech,billing}`
     * (with field-level `client_*` fallback), resolved via `$this->contact()`.
     */
    protected function buildContactSet(array $params): array
    {
        return [
            'owner' => $this->buildContact($params, 'owner'),
            'admin' => $this->buildContact($params, 'admin'),
            'billing' => $this->buildContact($params, 'billing'),
            'tech' => $this->buildContact($params, 'tech'),
        ];
    }

    /**
     * Build a single OpenSRS contact array from the canonical contact groups.
     *
     * Sources every field from `$this->contact($params, $group)` (PRC-969 —
     * populated canonical keys: first_name, last_name, company, email, phone,
     * address1, address2, city, state, postcode, country_code) and maps them to
     * the OpenSRS `contact_set` field names verified against the working
     * references (WHMCS opensrs.php:257,459; openSRS_base.php validate()).
     *
     * @param  string  $group  OpenSRS group: owner|admin|billing|tech
     */
    protected function buildContact(array $params, string $group = 'owner'): array
    {
        $canonicalGroup = self::CONTACT_GROUP_MAP[$group] ?? 'registrant';
        $c = $this->contact($params, $canonicalGroup);

        $company = trim((string) ($c['company'] ?? ''));
        $country = (string) ($c['country_code'] ?? '');

        return [
            'first_name' => $c['first_name'] ?? '',
            'last_name' => $c['last_name'] ?? '',
            // OpenSRS requires org_name; fall back to 'None' when the client has
            // no company (openSRS_base.php validate() rejects empty org_name).
            'org_name' => $company !== '' ? $company : 'None',
            'email' => $c['email'] ?? '',
            'address1' => $c['address1'] ?? '',
            'address2' => $c['address2'] ?? '',
            'address3' => '',
            'city' => $c['city'] ?? '',
            'state' => $c['state'] ?? '',
            'postal_code' => $c['postcode'] ?? '',
            'country' => $country,
            // OpenSRS validates phone as `+<countrycode>.<number>` — format
            // canonically so the contact_set is not rejected on register/transfer
            // (WHMCS getFormattedPhoneNumber, opensrs.php:581-596).
            'phone' => $this->formatPhone((string) ($c['phone'] ?? ''), $country),
            // Fax is not part of the canonical contact set; source from the raw
            // group when present, otherwise omit (OpenSRS treats it as optional).
            'fax' => $this->rawContactField($params, $canonicalGroup, 'fax'),
            'lang_pref' => 'EN',
        ];
    }

    /**
     * Read a non-canonical raw field (e.g. fax) from an emitted contact group,
     * falling back to the flat `client_*` param. Returns '' when absent.
     */
    protected function rawContactField(array $params, string $group, string $field): string
    {
        $raw = $params['contacts'][$group] ?? $params[$group] ?? [];

        if (is_array($raw) && ! empty($raw[$field])) {
            return (string) $raw[$field];
        }

        return (string) ($params['client_' . $field] ?? '');
    }

    // -------------------------------------------------------------------------
    //  TLD-restricted registration data (tld_data)
    // -------------------------------------------------------------------------

    /**
     * Extract the TLD portion (without leading dot) from a full domain name.
     *
     * PRC-49: derive the TLD from the domain STRING, never from tld_id
     * (which can drift). Returns the multi-label suffix ('co.uk' → 'co.uk')
     * best-effort by taking everything after the first label.
     */
    protected function extractTld(string $domain): string
    {
        $domain = strtolower(trim($domain));
        $parts = explode('.', $domain, 2);

        return $parts[1] ?? '';
    }

    /**
     * Return TLD-specific registration field schema (register/transfer extras).
     *
     * Mirrors the OpenSRS additionalfields WHMCS collects (opensrs.php:258-392)
     * so the core can prompt for + persist them in client_domain_fields; the
     * PRC-49 builder then re-emits them as $params['additional_fields'] and
     * buildTldData() maps them into tld_data.
     */
    public function getExtraFields(string $tld): array
    {
        $tld = strtolower(ltrim($tld, '.'));

        return match (true) {
            str_ends_with($tld, 'us') => [
                ['name' => 'Nexus Category', 'type' => 'text', 'required' => true],
                ['name' => 'Application Purpose', 'type' => 'dropdown', 'required' => true,
                    'options' => 'Business use for profit,Non-profit business,Club,Association,Religious Organization,Personal Use,Educational purposes,Government purposes'],
            ],
            str_ends_with($tld, 'it') => [
                ['name' => 'Legal Type', 'type' => 'dropdown', 'required' => true,
                    'options' => 'Italian and foreign natural persons,Companies/one man companies,Freelance workers/professionals,non-profit organizations,public organizations,other subjects,non natural foreigners'],
                ['name' => 'Tax ID', 'type' => 'text', 'required' => true],
            ],
            str_ends_with($tld, 'fr') => [
                ['name' => 'Legal Type', 'type' => 'dropdown', 'required' => true, 'options' => 'Individual,Organization'],
                ['name' => 'Birthdate', 'type' => 'text', 'required' => false],
                ['name' => 'Birthplace Country', 'type' => 'text', 'required' => false],
                ['name' => 'Birthplace City', 'type' => 'text', 'required' => false],
                ['name' => 'Birthplace Postcode', 'type' => 'text', 'required' => false],
                ['name' => 'VAT Number', 'type' => 'text', 'required' => false],
                ['name' => 'SIRET Number', 'type' => 'text', 'required' => false],
                ['name' => 'Trademark Number', 'type' => 'text', 'required' => false],
            ],
            str_ends_with($tld, 'pro') => [
                ['name' => 'Profession', 'type' => 'text', 'required' => true],
                ['name' => 'License Number', 'type' => 'text', 'required' => true],
                ['name' => 'Authority', 'type' => 'text', 'required' => true],
                ['name' => 'Authority Website', 'type' => 'text', 'required' => false],
            ],
            str_ends_with($tld, 'au') => [
                ['name' => 'Eligibility ID', 'type' => 'text', 'required' => true],
                ['name' => 'Eligibility ID Type', 'type' => 'dropdown', 'required' => true,
                    'options' => 'Australian Company Number (ACN),Australian Business Number (ABN),Trademark (TM)'],
                ['name' => 'Eligibility Name', 'type' => 'text', 'required' => false],
                ['name' => 'Eligibility Type', 'type' => 'text', 'required' => false],
                ['name' => 'Registrant Name', 'type' => 'text', 'required' => true],
            ],
            str_ends_with($tld, 'ca') => [
                ['name' => 'Legal Type', 'type' => 'dropdown', 'required' => true,
                    'options' => 'Corporation,Canadian Citizen,Government,Canadian Educational Institution,Canadian Unincorporated Association,Canadian Hospital,Partnership Registered in Canada,Trade-mark registered in Canada'],
            ],
            str_ends_with($tld, 'uk') => [
                ['name' => 'Legal Type', 'type' => 'dropdown', 'required' => true,
                    'options' => 'UK Limited Company,UK Public Limited Company,UK Partnership,UK Limited Liability Partnership,Sole Trader,UK Registered Charity,UK Industrial/Provident Registered Company,UK School,UK Government Body,UK Corporation by Royal Charter,UK Statutory Body,Non-UK Individual,UK Individual'],
                ['name' => 'Registrant Name', 'type' => 'text', 'required' => false],
                ['name' => 'Trademark Number', 'type' => 'text', 'required' => false],
            ],
            default => [],
        };
    }

    /**
     * Build the OpenSRS `tld_data` structure from $params['additional_fields'].
     *
     * Maps per-TLD registry-required fields the same way WHMCS does
     * (opensrs.php:258-392). The additional_fields map is flat key=>value; both
     * WHMCS-style labels ('Nexus Category') and slug variants are accepted.
     * Returns [] when the TLD needs no tld_data (the common gTLD case).
     */
    protected function buildTldData(string $domain, array $params): array
    {
        $tld = strtolower($this->extractTld($domain));
        $af = is_array($params['additional_fields'] ?? null) ? $params['additional_fields'] : [];

        // Owner country for registries that key off it (.it nationality).
        $ownerCountry = $this->contact($params, 'registrant')['country_code'] ?? '';
        $ownerCompany = trim((string) ($this->contact($params, 'registrant')['company'] ?? ''));

        if (str_ends_with($tld, 'au')) {
            return ['au_registrant_info' => [
                'eligibility_id' => $this->af($af, 'Eligibility ID', 'eligibility_id'),
                'eligibility_id_type' => $this->mapAuEligibilityType($this->af($af, 'Eligibility ID Type', 'eligibility_id_type')),
                'eligibility_name' => $this->af($af, 'Eligibility Name', 'eligibility_name'),
                'eligibility_type' => $this->af($af, 'Eligibility Type', 'eligibility_type'),
                'registrant_name' => $this->af($af, 'Registrant Name', 'registrant_name'),
            ]];
        }

        if (str_ends_with($tld, 'us')) {
            return ['nexus' => [
                'category' => $this->af($af, 'Nexus Category', 'nexus_category'),
                'app_purpose' => $this->mapUsAppPurpose($this->af($af, 'Application Purpose', 'app_purpose')),
            ]];
        }

        if (str_ends_with($tld, 'it')) {
            return ['it_registrant_info' => [
                'nationality_code' => $ownerCountry,
                'reg_code' => $this->af($af, 'Tax ID', 'tax_id', 'reg_code'),
                'entity_type' => $this->mapItEntityType($this->af($af, 'Legal Type', 'legal_type'), $ownerCompany),
            ]];
        }

        if (str_ends_with($tld, 'pro')) {
            return ['professional_data' => [
                'profession' => $this->af($af, 'Profession', 'profession'),
                'license_number' => $this->af($af, 'License Number', 'license_number'),
                'authority' => $this->af($af, 'Authority', 'authority'),
                'authority_website' => $this->af($af, 'Authority Website', 'authority_website'),
            ]];
        }

        if (str_ends_with($tld, 'fr')) {
            $legalType = $this->af($af, 'Legal Type', 'legal_type');
            if (strcasecmp($legalType, 'Individual') === 0) {
                $birthCountry = strtoupper($this->af($af, 'Birthplace Country', 'birthplace_country') ?: $ownerCountry);
                $fr = [
                    'registrant_type' => 'individual',
                    'country_of_birth' => $birthCountry,
                    'date_of_birth' => $this->af($af, 'Birthdate', 'date_of_birth', 'birthdate'),
                ];
                if ($birthCountry === 'FR') {
                    $fr['place_of_birth'] = $this->af($af, 'Birthplace City', 'birthplace_city');
                    $fr['postal_code_of_birth'] = $this->af($af, 'Birthplace Postcode', 'birthplace_postcode');
                }
            } else {
                $fr = [
                    'registrant_type' => 'organization',
                    'registrant_vat_id' => $this->af($af, 'VAT Number', 'vat_number'),
                    'siren_siret' => $this->af($af, 'SIRET Number', 'siret_number'),
                    'trademark_number' => $this->af($af, 'Trademark Number', 'trademark_number'),
                ];
            }

            return ['registrant_extra_info' => $fr];
        }

        // .ca / .uk carry their registry-restricted registrant legal type in the
        // top-level `legal_type` attribute (see buildLegalType), not a nested
        // tld_data block, so no tld_data is emitted for them.
        return [];
    }

    /**
     * Map the "Legal Type" additional field to the OpenSRS registrant legal-type
     * code required by .uk and .ca registrations. Other TLDs pass the raw label
     * through (usually empty). Ref: WHMCS opensrs.php:125-223.
     */
    protected function buildLegalType(string $domain, array $af): string
    {
        $tld = strtolower($this->extractTld($domain));
        $legalType = $this->af($af, 'Legal Type', 'legal_type');

        if (str_ends_with($tld, 'uk')) {
            return match ($legalType) {
                'UK Limited Company' => 'LTD',
                'UK Public Limited Company' => 'PLC',
                'UK Partnership' => 'PTNR',
                'UK Limited Liability Partnership' => 'LLP',
                'Sole Trader' => 'STRA',
                'UK Registered Charity' => 'RCHAR',
                'UK Industrial/Provident Registered Company' => 'IP',
                'UK School' => 'SCH',
                'UK Government Body' => 'GOV',
                'UK Corporation by Royal Charter' => 'CRC',
                'UK Statutory Body' => 'STAT',
                'Non-UK Individual' => 'FIND',
                default => 'IND',
            };
        }

        if (str_ends_with($tld, 'ca')) {
            return match ($legalType) {
                'Corporation' => 'CCO',
                'Canadian Citizen' => 'CCT',
                'Government' => 'GOV',
                'Canadian Educational Institution' => 'EDU',
                'Canadian Unincorporated Association' => 'ASS',
                'Canadian Hospital' => 'HOP',
                'Partnership Registered in Canada' => 'PRT',
                'Trade-mark registered in Canada' => 'TDM',
                default => 'CCT',
            };
        }

        return $legalType;
    }

    /**
     * First non-empty value from a flat additional_fields map, tried across
     * the given candidate keys (WHMCS label + slug variants).
     */
    protected function af(array $af, string ...$keys): string
    {
        foreach ($keys as $key) {
            if (isset($af[$key]) && trim((string) $af[$key]) !== '') {
                return (string) $af[$key];
            }
        }

        return '';
    }

    /**
     * Map a .us Application Purpose label to its OpenSRS code (P1..P5).
     * Ref: WHMCS opensrs.php:302-333.
     */
    protected function mapUsAppPurpose(string $purpose): string
    {
        return match ($purpose) {
            'Business use for profit' => 'P1',
            'Non-profit business', 'Club', 'Association', 'Religious Organization' => 'P2',
            'Personal Use' => 'P3',
            'Educational purposes' => 'P4',
            'Government purposes' => 'P5',
            default => $purpose, // already a code (P1..P5) or blank
        };
    }

    /**
     * Map a .au Eligibility ID Type label to its OpenSRS code.
     * Ref: WHMCS opensrs.php:260-294.
     */
    protected function mapAuEligibilityType(string $type): string
    {
        return match ($type) {
            'Australian Company Number (ACN)' => 'ACN',
            'ACT Business Number', 'Australian Business Number (ABN)' => 'ABN',
            'NSW Business Number' => 'NSW BN',
            'NT Business Number' => 'NT BN',
            'QLD Business Number' => 'QLD BN',
            'SA Business Number' => 'SA BN',
            'TAS Business Number' => 'TAS BN',
            'VIC Business Number' => 'VIC BN',
            'WA Business Number' => 'WA BN',
            'Trademark (TM)' => 'TM',
            '' => '',
            default => in_array($type, ['ACN', 'ABN', 'TM'], true) ? $type : 'OTHER',
        };
    }

    /**
     * Map a .it Legal Type label to its OpenSRS entity_type number.
     * Ref: WHMCS opensrs.php:337-362.
     */
    protected function mapItEntityType(string $legalType, string $company): string
    {
        return match ($legalType) {
            'Italian and foreign natural persons' => '1',
            'Companies/one man companies' => '2',
            'Freelance workers/professionals' => '3',
            'non-profit organizations' => '4',
            'public organizations' => '5',
            'other subjects' => '6',
            'non natural foreigners' => '7',
            default => $company !== '' ? '2' : '1',
        };
    }

    // -------------------------------------------------------------------------
    //  Transfer / renew / privacy / suggestion helpers
    // -------------------------------------------------------------------------

    /**
     * Whether the TLD requires owner_confirm_address on transfer (.au/.de/.be/
     * .eu/.it). Ref: WHMCS opensrs.php:460.
     */
    protected function tldRequiresOwnerConfirm(string $domain): bool
    {
        $tld = strtolower($this->extractTld($domain));

        foreach (['au', 'de', 'be', 'eu', 'it'] as $suffix) {
            if (str_ends_with($tld, $suffix)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolve the real CURRENT registry-expiry year for a renew, from any of
     * the caller-supplied fields. Returns null when unavailable (NO date('Y')
     * fallback — OpenSRS rejects a mismatched currentexpirationyear).
     */
    protected function resolveCurrentExpiryYear(array $params): ?string
    {
        // Explicit year field.
        foreach (['expiry_year', 'currentexpirationyear', 'current_expiry_year'] as $key) {
            if (! empty($params[$key]) && preg_match('/^\d{4}$/', (string) $params[$key])) {
                return (string) $params[$key];
            }
        }

        // Derive from a full expiry date (WHMCS substr(expirydate,0,4)).
        foreach (['expiry_date', 'expirydate', 'expires_at', 'expire_date'] as $key) {
            $value = (string) ($params[$key] ?? '');
            if ($value !== '' && preg_match('/(\d{4})/', $value, $m)) {
                return $m[1];
            }
        }

        return null;
    }

    /**
     * Resolve the privacy enable flag from the module params.
     */
    protected function resolvePrivacyEnabled(array $params): bool
    {
        if (array_key_exists('id_protection', $params)) {
            return (bool) $params['id_protection'];
        }
        if (array_key_exists('enabled', $params)) {
            return (bool) $params['enabled'];
        }
        if (array_key_exists('enable', $params)) {
            return (bool) $params['enable'];
        }
        if (isset($params['state'])) {
            return strcasecmp((string) $params['state'], 'enable') === 0;
        }

        return false;
    }

    /**
     * Normalise a caller-supplied TLD list to the dotted OpenSRS form ('.com').
     */
    protected function normalizeSuggestionTlds(mixed $tlds): array
    {
        if (is_string($tlds)) {
            $tlds = preg_split('/[,\s]+/', $tlds, -1, PREG_SPLIT_NO_EMPTY) ?: [];
        }

        if (! is_array($tlds)) {
            return [];
        }

        $out = [];
        foreach ($tlds as $tld) {
            $tld = strtolower(trim((string) $tld));
            if ($tld === '') {
                continue;
            }
            $out[] = '.' . ltrim($tld, '.');
        }

        return array_values(array_unique($out));
    }

    /**
     * Distinguish a genuine "domain not found in the reseller account" API
     * failure (transfer still in progress) from other errors (auth/connection).
     * OpenSRS returns response_code 400/404/415 with "not found"/"does not
     * exist" text for domains not yet under the profile.
     */
    protected function isDomainNotFound(array $response): bool
    {
        $text = strtolower((string) ($response['response_text'] ?? ''));
        $code = (string) ($response['response_code'] ?? '');

        if (str_contains($text, 'not found')
            || str_contains($text, 'does not exist')
            || str_contains($text, 'no such domain')
            || str_contains($text, 'not exist')) {
            return true;
        }

        return in_array($code, ['400', '404', '415'], true);
    }

    // -------------------------------------------------------------------------
    //  State-code normalisation (contact modify)
    // -------------------------------------------------------------------------

    /**
     * Convert a US/CA state name to its 2-letter ISO code, mirroring the WHMCS
     * convertStateToCode() the reference applies on every contact state
     * (opensrs.php:606). Non-US/CA countries or already-coded values pass
     * through unchanged.
     */
    protected function normalizeState(string $state, string $country): string
    {
        $state = trim($state);
        if ($state === '') {
            return '';
        }

        // Already a 2-letter code.
        if (preg_match('/^[A-Za-z]{2}$/', $state)) {
            return strtoupper($state);
        }

        $country = strtoupper(trim($country));
        $map = match ($country) {
            'US', 'USA', 'UNITED STATES' => self::US_STATE_CODES,
            'CA', 'CAN', 'CANADA' => self::CA_STATE_CODES,
            default => [],
        };

        $key = strtolower($state);

        return $map[$key] ?? $state;
    }

    /**
     * Convert a Canadian province ISO code to the CIRA state code CIRA expects
     * for .ca (they are identical 2-letter codes, but normalise/upper-case for
     * safety). Mirrors WHMCS convertToCiraCode() (opensrs.php:610-612).
     */
    protected function convertToCiraCode(string $state): string
    {
        $state = strtoupper(trim($state));

        // WHMCS convertToCiraCode() only remaps YT → YK; every other province
        // ISO code passes through unchanged (registrarfunctions.php:638-644).
        if ($state === 'YT') {
            return 'YK';
        }

        return $state;
    }

    protected const US_STATE_CODES = [
        'alabama' => 'AL', 'alaska' => 'AK', 'arizona' => 'AZ', 'arkansas' => 'AR',
        'california' => 'CA', 'colorado' => 'CO', 'connecticut' => 'CT', 'delaware' => 'DE',
        'district of columbia' => 'DC', 'florida' => 'FL', 'georgia' => 'GA', 'hawaii' => 'HI',
        'idaho' => 'ID', 'illinois' => 'IL', 'indiana' => 'IN', 'iowa' => 'IA',
        'kansas' => 'KS', 'kentucky' => 'KY', 'louisiana' => 'LA', 'maine' => 'ME',
        'maryland' => 'MD', 'massachusetts' => 'MA', 'michigan' => 'MI', 'minnesota' => 'MN',
        'mississippi' => 'MS', 'missouri' => 'MO', 'montana' => 'MT', 'nebraska' => 'NE',
        'nevada' => 'NV', 'new hampshire' => 'NH', 'new jersey' => 'NJ', 'new mexico' => 'NM',
        'new york' => 'NY', 'north carolina' => 'NC', 'north dakota' => 'ND', 'ohio' => 'OH',
        'oklahoma' => 'OK', 'oregon' => 'OR', 'pennsylvania' => 'PA', 'rhode island' => 'RI',
        'south carolina' => 'SC', 'south dakota' => 'SD', 'tennessee' => 'TN', 'texas' => 'TX',
        'utah' => 'UT', 'vermont' => 'VT', 'virginia' => 'VA', 'washington' => 'WA',
        'west virginia' => 'WV', 'wisconsin' => 'WI', 'wyoming' => 'WY',
    ];

    protected const CA_STATE_CODES = [
        'alberta' => 'AB', 'british columbia' => 'BC', 'manitoba' => 'MB',
        'new brunswick' => 'NB', 'newfoundland and labrador' => 'NL', 'newfoundland' => 'NL',
        'nova scotia' => 'NS', 'northwest territories' => 'NT', 'nunavut' => 'NU',
        'ontario' => 'ON', 'prince edward island' => 'PE', 'quebec' => 'QC',
        'saskatchewan' => 'SK', 'yukon' => 'YT',
    ];

    // -------------------------------------------------------------------------
    //  Phone normalisation (contact_set)
    // -------------------------------------------------------------------------

    /**
     * Format a phone number into the OpenSRS `+<countrycode>.<number>` form.
     *
     * Mirrors WHMCS getFormattedPhoneNumber (opensrs.php:581-596): if the value
     * already matches `+CC.number` it is returned as-is; otherwise every
     * non-digit is stripped and — when the country's calling code is known —
     * `+CC.` is prepended. When the calling code is unknown the bare digits are
     * returned (exactly what WHMCS does when getCallingCode() is empty), which
     * OpenSRS still accepts for many gTLDs.
     *
     * @param  string  $phone         Raw phone (any format, may already be +CC.)
     * @param  string  $countryCode   ISO-3166 alpha-2 (e.g. 'GB')
     */
    protected function formatPhone(string $phone, string $countryCode): string
    {
        $phone = trim($phone);
        if ($phone === '') {
            return '';
        }

        // Already canonical `+CC.number` — leave untouched.
        if (preg_match('/^\+\d+\.\d+$/', $phone)) {
            return $phone;
        }

        $digits = preg_replace('/[^\d]+/', '', $phone) ?? '';
        if ($digits === '') {
            return '';
        }

        $calling = self::CALLING_CODES[strtoupper(trim($countryCode))] ?? '';

        return $calling !== '' ? '+' . $calling . '.' . $digits : $digits;
    }

    /**
     * ISO-3166 alpha-2 → E.164 calling code for the common registrar markets.
     * Not exhaustive — an unknown country yields the bare-digits fallback (see
     * formatPhone()). Extend as needed; never throws.
     */
    protected const CALLING_CODES = [
        'US' => '1', 'CA' => '1', 'GB' => '44', 'UK' => '44', 'DE' => '49',
        'FR' => '33', 'IT' => '39', 'ES' => '34', 'NL' => '31', 'BE' => '32',
        'AU' => '61', 'NZ' => '64', 'IE' => '353', 'AT' => '43', 'CH' => '41',
        'SE' => '46', 'NO' => '47', 'DK' => '45', 'FI' => '358', 'PL' => '48',
        'PT' => '351', 'GR' => '30', 'TR' => '90', 'RU' => '7', 'UA' => '380',
        'IN' => '91', 'CN' => '86', 'JP' => '81', 'KR' => '82', 'SG' => '65',
        'HK' => '852', 'AE' => '971', 'SA' => '966', 'IL' => '972', 'ZA' => '27',
        'BR' => '55', 'MX' => '52', 'AR' => '54', 'CL' => '56', 'CO' => '57',
    ];

    /**
     * Generate a username for OpenSRS domain management.
     */
    protected function generateUsername(string $domain): string
    {
        $username = preg_replace('/[^a-zA-Z]/', '', $domain);

        return substr($username, 0, 8);
    }

    /**
     * Generate a random password for OpenSRS domain management.
     */
    protected function generatePassword(): string
    {
        return substr(sha1(uniqid((string) mt_rand(), true)), 0, 10);
    }

    /**
     * Check whether an array is associative (has string keys).
     */
    protected function isAssoc(array $arr): bool
    {
        if (empty($arr)) {
            return true;
        }

        return array_keys($arr) !== range(0, count($arr) - 1);
    }
}
