<?php

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;

/**
 * DomainService — AI domain suggestions + NameSilo reseller (check / register).
 *
 * Verified NameSilo contract (2026-08-07, namesilo.com/api-reference):
 *   GET https://www.namesilo.com/api/OPERATION?version=1&type=json&key=KEY
 *   - checkRegisterAvailability : domains=comma-list (<= 200 per call)
 *   - registerDomain            : domain, years(1-10), private(1/0),
 *                                 auto_renew(1/0), ns1..ns13, contact ids
 *   - getPrices                 : per-TLD wholesale
 *   Success is reply.code == 300. All operations MUST be GET.
 *
 * Chosen because it fits Sergei's hard constraints (2026-08-07): no monthly or
 * yearly fee, no volume minimum, free bulk search. We pay wholesale only when a
 * client buys.
 *
 * INERT until `instinctor_namesilo.api_key` is set (see isEnabled). Shipping this
 * file changes nothing in production until the key is configured.
 *
 * The AI naming layer is OURS (AIContentService relay), never a third party.
 */
class DomainService
{
    /**
     * order_items.meta contract the checkout intake must write for a line to be
     * treated as a domain to register (the "Pick your domain" step sets these):
     *   META_DOMAIN  => the fully-qualified domain, e.g. "acmeplumbing.com"
     *   META_YEARS   => registration years (int, default 1)
     *   META_PAGE_ID => optional page/site id the domain will point at
     */
    public const META_DOMAIN  = 'instinctor_domain';
    public const META_YEARS   = 'instinctor_domain_years';
    public const META_PAGE_ID = 'instinctor_domain_page_id';

    private const TABLE = 'instinctor_domains';

    /**
     * Register the post-payment fulfillment listener. Called once at boot from
     * Plugin::init. Subscribes to every "money captured" signal:
     *   - instinctor_order_finalized : the universal automated path (inline,
     *     /checkout/confirm, and the payment_intent.succeeded webhook)
     *   - instinctor_order_paid / _completed : the admin manual "mark paid" path
     * The UNIQUE(order_id, domain) claim makes multiple firings for one order safe.
     */
    public static function boot(): void
    {
        add_action('instinctor_order_finalized', [self::class, 'fulfillOrder']);
        add_action('instinctor_order_paid', [self::class, 'fulfillOrder']);
        add_action('instinctor_order_completed', [self::class, 'fulfillOrder']);
    }

    private static function settings(): array
    {
        $defaults = [
            'api_key'     => '',
            'base'        => 'https://www.namesilo.com/api/', // swap to sandbox base when issued
            'ns1'         => '',   // Instinctor hosting nameservers, so a bought domain resolves to the built site
            'ns2'         => '',
            'markup_pct'  => 40,   // retail = wholesale * 1.40 ...
            'markup_flat' => 0,    // ... or a flat dollar add instead (takes precedence when > 0)
        ];
        return array_merge($defaults, (array) Config::get('instinctor_namesilo', []));
    }

    /** The whole feature is off until a key exists. */
    public static function isEnabled(): bool
    {
        return trim((string) (self::settings()['api_key'] ?? '')) !== '';
    }

    /** Retail price from wholesale, per the configured markup. */
    public static function retail(float $wholesale): float
    {
        $s = self::settings();
        $flat = (float) ($s['markup_flat'] ?? 0);
        if ($flat > 0) {
            return round($wholesale + $flat, 2);
        }
        return round($wholesale * (1 + ((float) ($s['markup_pct'] ?? 0)) / 100), 2);
    }

    /**
     * Low-level GET to NameSilo. Returns the decoded `reply` array on success
     * (reply.code 300), or a WP_Error on transport / API failure.
     *
     * @return array|\WP_Error
     */
    private static function call(string $operation, array $params)
    {
        $s = self::settings();
        $key = trim((string) $s['api_key']);
        if ($key === '') {
            return new \WP_Error('namesilo_unconfigured', 'NameSilo API key is not set');
        }

        $params = array_merge(['version' => '1', 'type' => 'json', 'key' => $key], $params);
        $url = rtrim((string) $s['base'], '/') . '/' . $operation . '?' . http_build_query($params);

        $res = wp_remote_get($url, ['timeout' => 20]);
        if (is_wp_error($res)) {
            return $res;
        }

        $body  = json_decode((string) wp_remote_retrieve_body($res), true);
        $reply = is_array($body) ? ($body['reply'] ?? null) : null;
        if (!is_array($reply)) {
            return new \WP_Error('namesilo_badresponse', 'Unexpected NameSilo response', ['body' => $body]);
        }
        // NameSilo signals success with code 300.
        if ((string) ($reply['code'] ?? '') !== '300') {
            return new \WP_Error('namesilo_error', (string) ($reply['detail'] ?? 'NameSilo error'), $reply);
        }
        return $reply;
    }

    /**
     * NameSilo's XML-derived JSON wraps single items as objects, not lists.
     * NOTE: validate the exact available/domain/price keys against a real
     * sandbox response before turning the feature on; the shape is fuzzy.
     */
    private static function asList($v): array
    {
        if ($v === null) {
            return [];
        }
        if (is_array($v) && ($v === [] || array_keys($v) === range(0, count($v) - 1))) {
            return $v; // already a 0-indexed list
        }
        return [$v]; // single item, wrap it
    }

    private static function domainText($item): string
    {
        if (is_string($item)) {
            return strtolower(trim($item));
        }
        if (is_array($item)) {
            foreach (['domain', '@content', 'content', 'value'] as $k) {
                if (!empty($item[$k]) && is_string($item[$k])) {
                    return strtolower(trim($item[$k]));
                }
            }
        }
        return '';
    }

    /**
     * Bulk availability. Any number of domains; chunked to 200 per call.
     * A failed chunk is skipped, not fatal.
     *
     * @return array{available: array<int,array>, unavailable: array<int,string>, invalid: array<int,string>}
     */
    public static function check(array $domains): array
    {
        $out = ['available' => [], 'unavailable' => [], 'invalid' => []];
        $domains = array_values(array_unique(array_filter(array_map(
            static fn($d) => strtolower(trim((string) $d)),
            $domains
        ))));

        foreach (array_chunk($domains, 200) as $chunk) {
            $reply = self::call('checkRegisterAvailability', ['domains' => implode(',', $chunk)]);
            if (is_wp_error($reply)) {
                continue;
            }

            foreach (self::asList($reply['available']['domain'] ?? null) as $a) {
                $name = self::domainText($a);
                if ($name === '') {
                    continue;
                }
                $ws = (is_array($a) && isset($a['price'])) ? (float) $a['price'] : null;
                $out['available'][] = [
                    'domain'    => $name,
                    'wholesale' => $ws,
                    'retail'    => $ws !== null ? self::retail($ws) : null,
                    'premium'   => is_array($a) && !empty($a['premium']) && (string) $a['premium'] !== '0',
                ];
            }
            foreach (self::asList($reply['unavailable']['domain'] ?? null) as $u) {
                if (($t = self::domainText($u)) !== '') {
                    $out['unavailable'][] = $t;
                }
            }
            foreach (self::asList($reply['invalid']['domain'] ?? null) as $i) {
                if (($t = self::domainText($i)) !== '') {
                    $out['invalid'][] = $t;
                }
            }
        }
        return $out;
    }

    /**
     * Register a domain. CALL ONLY AFTER CONFIRMED PAYMENT, and re-check
     * availability at that moment (a suggested domain can be taken in the gap).
     * ns1/ns2 default to Instinctor hosting so the domain resolves to the site.
     * rid/tid/aid/bid = registrant/tech/admin/billing contact ids (the CLIENT
     * owns it); absent, NameSilo uses the account default contact.
     *
     * @return array|\WP_Error
     */
    public static function register(string $domain, array $opts = [])
    {
        $s = self::settings();
        $params = [
            'domain'     => strtolower(trim($domain)),
            'years'      => max(1, min(10, (int) ($opts['years'] ?? 1))),
            'private'    => isset($opts['private']) ? (int) (bool) $opts['private'] : 1,
            'auto_renew' => isset($opts['auto_renew']) ? (int) (bool) $opts['auto_renew'] : 1,
        ];
        $ns1 = trim((string) ($opts['ns1'] ?? $s['ns1']));
        $ns2 = trim((string) ($opts['ns2'] ?? $s['ns2']));
        if ($ns1 !== '') {
            $params['ns1'] = $ns1;
        }
        if ($ns2 !== '') {
            $params['ns2'] = $ns2;
        }
        foreach (['rid', 'tid', 'aid', 'bid'] as $k) {
            if (!empty($opts[$k])) {
                $params[$k] = $opts[$k];
            }
        }
        return self::call('registerDomain', $params);
    }

    /**
     * Store-path fulfillment. Hooked to instinctor_order_finalized (and the
     * admin manual paid/completed signals). For every order line whose meta
     * carries a domain to register (see META_* contract), it builds a fulfill()
     * context and delegates. This is the instinctor.com own-store path (the
     * store is Instinctor, so the money is ours); the InCommand self-serve path
     * goes through the platform and calls fulfill() from the /solo/domain-fulfill
     * webhook instead. Both funnel into the one fulfill() below.
     *
     * Inert unless the feature is configured (isEnabled).
     */
    public static function fulfillOrder(int $orderId): void
    {
        if ($orderId <= 0 || !self::isEnabled()) {
            return;
        }

        $p = DB::prefix();

        $order = DB::get_row(DB::prepare(
            "SELECT o.id, o.customer_id, c.owner_id
             FROM {$p}instinctor_orders o
             LEFT JOIN {$p}instinctor_customers c ON c.id = o.customer_id
             WHERE o.id = %d",
            $orderId
        ));
        if (!$order) {
            return;
        }

        $items = DB::get_results(DB::prepare(
            "SELECT id, meta FROM {$p}instinctor_order_items WHERE order_id = %d",
            $orderId
        ));
        if (empty($items)) {
            return;
        }

        foreach ($items as $item) {
            $meta = json_decode((string) ($item->meta ?? ''), true);
            if (!is_array($meta)) {
                continue;
            }
            $domain = strtolower(trim((string) ($meta[self::META_DOMAIN] ?? '')));
            if ($domain === '') {
                continue; // not a domain line
            }
            self::fulfill([
                'purchase_ref'  => 'order:' . $orderId . ':' . (int) $item->id,
                'source'        => 'order',
                'domain'        => $domain,
                'years'         => (int) ($meta[self::META_YEARS] ?? 1),
                'owner_id'      => isset($order->owner_id) ? ((int) $order->owner_id ?: null) : null,
                'customer_id'   => isset($order->customer_id) ? ((int) $order->customer_id ?: null) : null,
                'order_id'      => $orderId,
                'order_item_id' => (int) $item->id,
                'page_id'       => (int) ($meta[self::META_PAGE_ID] ?? 0) ?: null,
            ]);
        }
    }

    /**
     * Register one paid domain, idempotently. The single fulfillment entry point
     * shared by every buy path (store order, InCommand -> platform checkout).
     *
     *   1. Claim/lock a ledger row keyed on $ctx['purchase_ref'] (a per-payment
     *      token). A duplicate firing for the same payment is a safe no-op.
     *   2. RE-CHECK availability now (a suggested name can be taken between
     *      selection and payment).
     *   3. register() at NameSilo, pointed at Instinctor hosting nameservers.
     *   4. Record the outcome and fire an event hook.
     *
     * Money is already captured before this runs, so an unavailable/failed domain
     * is recorded and announced (instinctor_domain_unavailable / _register_failed)
     * for a refund-or-alternative business step; never silently swallowed, never
     * double-charged, never double-registered.
     *
     * $ctx keys: purchase_ref (required), domain (required), years, owner_id,
     * customer_id, order_id, order_item_id, page_id, retail_charged (what the
     * buyer actually paid), source ('order'|'platform').
     *
     * @return array{status:string, row_id?:int, detail?:string}
     *   status ∈ disabled|invalid|claimed_elsewhere|registered|unavailable|failed|pending_review
     */
    public static function fulfill(array $ctx): array
    {
        if (!self::isEnabled()) {
            return ['status' => 'disabled'];
        }

        $ref    = trim((string) ($ctx['purchase_ref'] ?? ''));
        $domain = strtolower(trim((string) ($ctx['domain'] ?? '')));
        if ($ref === '' || $domain === '') {
            return ['status' => 'invalid'];
        }

        $years         = max(1, min(10, (int) ($ctx['years'] ?? 1)));
        $ownerId       = isset($ctx['owner_id']) ? ((int) $ctx['owner_id'] ?: null) : null;
        $orderId       = isset($ctx['order_id']) ? ((int) $ctx['order_id'] ?: null) : null;
        $pageId        = isset($ctx['page_id']) ? ((int) $ctx['page_id'] ?: null) : null;
        $source        = (string) ($ctx['source'] ?? 'order');
        $retailCharged = isset($ctx['retail_charged']) ? (float) $ctx['retail_charged'] : null;

        $p = DB::prefix();

        // (1) Idempotency. Look up any existing claim for this exact payment.
        $existing = DB::get_row(DB::prepare(
            "SELECT id, status FROM {$p}instinctor_domains WHERE purchase_ref = %s",
            $ref
        ));

        if ($existing) {
            // Terminal states never re-run — a retry for the same payment is a no-op.
            if (in_array((string) $existing->status, ['registered', 'unavailable', 'failed'], true)) {
                return ['status' => (string) $existing->status, 'row_id' => (int) $existing->id];
            }
            // status == pending: a prior attempt was interrupted before it reached
            // a terminal state. Re-drive it (below) ONLY if the name is provably
            // still free; if it is now taken we cannot tell whether the crashed
            // attempt registered it under our account, so we leave it for
            // reconciliation rather than risk a double registration.
            $rowId    = (int) $existing->id;
            $redriven = true;
        } else {
            $claimed = DB::insert($p . self::TABLE, [
                'owner_id'      => $ownerId,
                'customer_id'   => isset($ctx['customer_id']) ? ((int) $ctx['customer_id'] ?: null) : null,
                'order_id'      => $orderId,
                'order_item_id' => isset($ctx['order_item_id']) ? ((int) $ctx['order_item_id'] ?: null) : null,
                'page_id'       => $pageId,
                'domain'        => $domain,
                'years'         => $years,
                'provider'      => 'namesilo',
                'purchase_ref'  => $ref,
                'source'        => $source,
                'status'        => 'pending',
                'created_at'    => current_time('mysql'),
                'updated_at'    => current_time('mysql'),
            ]);
            if (!$claimed) {
                // Lost the UNIQUE(purchase_ref) race to a concurrent worker.
                return ['status' => 'claimed_elsewhere'];
            }
            $rowId    = DB::insert_id();
            $redriven = false;
        }

        // (2) Re-check availability at payment time.
        $check = self::check([$domain]);
        $match = null;
        foreach ($check['available'] as $a) {
            if (($a['domain'] ?? '') === $domain) {
                $match = $a;
                break;
            }
        }
        if ($match === null) {
            if ($redriven) {
                // Ambiguous: the interrupted attempt may already own it. Do not
                // register again; flag for reconciliation.
                self::finishDomain($rowId, 'pending', 'Re-check found domain unavailable on retry; needs reconciliation (may already be registered under our account)', null, $retailCharged);
                return ['status' => 'pending_review', 'row_id' => $rowId];
            }
            self::finishDomain($rowId, 'unavailable', 'Domain was no longer available at payment time', null, $retailCharged);
            do_action('instinctor_domain_unavailable', $rowId, $domain, $ownerId, $orderId, $source);
            return ['status' => 'unavailable', 'row_id' => $rowId];
        }

        $wholesale = (float) ($match['wholesale'] ?? 0) ?: null;
        $retail    = $retailCharged ?? ((float) ($match['retail'] ?? 0) ?: null);

        // (3) Register, pointed at Instinctor hosting nameservers (settings ns1/ns2).
        $reply = self::register($domain, ['years' => $years]);
        if (is_wp_error($reply)) {
            self::finishDomain($rowId, 'failed', $reply->get_error_message(), $wholesale, $retail);
            do_action('instinctor_domain_register_failed', $rowId, $domain, $reply, $orderId, $source);
            return ['status' => 'failed', 'row_id' => $rowId, 'detail' => $reply->get_error_message()];
        }

        // (4) Success. NameSilo echoes the domain and, on some plans, an order id.
        $providerRef = '';
        foreach (['domain_id', 'order_id', 'domain'] as $k) {
            if (!empty($reply[$k])) {
                $providerRef = (string) $reply[$k];
                break;
            }
        }
        self::finishDomain($rowId, 'registered', null, $wholesale, $retail, $providerRef);
        do_action('instinctor_domain_registered', $rowId, $domain, $ownerId, $orderId, $pageId, $source);
        return ['status' => 'registered', 'row_id' => $rowId];
    }

    /** Update a ledger row's outcome. */
    private static function finishDomain(int $rowId, string $status, ?string $detail, ?float $wholesale, ?float $retail, string $ref = ''): void
    {
        $data = [
            'status'     => $status,
            'detail'     => $detail,
            'updated_at' => current_time('mysql'),
        ];
        if ($wholesale !== null) {
            $data['wholesale'] = $wholesale;
        }
        if ($retail !== null) {
            $data['retail'] = $retail;
        }
        if ($ref !== '') {
            $data['provider_ref'] = $ref;
        }
        if ($status === 'registered') {
            $data['registered_at'] = current_time('mysql');
        }
        DB::update(DB::prefix() . self::TABLE, $data, ['id' => $rowId]);
    }

    /**
     * AI candidates + bulk availability, exact-name matches first.
     * $biz = ['name','trade','city','description'].
     *
     * @return array{available: array, unavailable: array, invalid: array, candidates: array}
     */
    public static function suggest(array $biz, int $count = 20): array
    {
        $candidates = self::aiCandidates($biz, $count);
        if (!$candidates) {
            return ['available' => [], 'unavailable' => [], 'invalid' => [], 'candidates' => []];
        }

        $res = self::check($candidates);

        $slug = preg_replace('/[^a-z0-9]/', '', strtolower((string) ($biz['name'] ?? '')));
        usort($res['available'], static function ($a, $b) use ($slug) {
            $ax = ($slug !== '' && strpos($a['domain'], $slug . '.') === 0) ? 0 : 1;
            $bx = ($slug !== '' && strpos($b['domain'], $slug . '.') === 0) ? 0 : 1;
            if ($ax !== $bx) {
                return $ax - $bx;
            }
            // then cheaper / shorter
            return strlen($a['domain']) <=> strlen($b['domain']);
        });

        $res['candidates'] = $candidates;
        return $res;
    }

    /** LLM candidate generation via our own relay, with a deterministic fallback. */
    private static function aiCandidates(array $biz, int $count): array
    {
        $name  = trim((string) ($biz['name'] ?? ''));
        $trade = trim((string) ($biz['trade'] ?? ''));
        $city  = trim((string) ($biz['city'] ?? ''));
        $desc  = trim((string) ($biz['description'] ?? ''));

        $prompt = "Suggest {$count} domain names for a small business.\n"
            . "Business name: {$name}\nTrade: {$trade}\nCity: {$city}\nAbout: {$desc}\n"
            . "Rules: prefer .com, then .co and .net, plus one or two trade-relevant TLDs. "
            . "Short, pronounceable, lowercase, no hyphens, no trademarks. Mix exact business "
            . "name, name plus trade, name plus city, and two or three brandable coinages.\n"
            . "Return ONLY a JSON array of domain strings, nothing else.";

        $raw = AIContentService::generateContent($prompt, 400);
        if (is_string($raw) && preg_match('/\[.*\]/s', $raw, $m)) {
            $arr = json_decode($m[0], true);
            if (is_array($arr)) {
                $clean = [];
                foreach ($arr as $d) {
                    $d = strtolower(trim((string) $d));
                    if (preg_match('/^[a-z0-9-]{1,63}\.[a-z][a-z.]{1,}$/', $d)) {
                        $clean[] = $d;
                    }
                }
                if ($clean) {
                    return array_slice(array_values(array_unique($clean)), 0, $count);
                }
            }
        }
        return self::fallbackCandidates($biz);
    }

    /** Deterministic candidates if the AI relay is unavailable. */
    private static function fallbackCandidates(array $biz): array
    {
        $name  = preg_replace('/[^a-z0-9]/', '', strtolower((string) ($biz['name'] ?? '')));
        $trade = preg_replace('/[^a-z0-9]/', '', strtolower((string) ($biz['trade'] ?? '')));
        $city  = preg_replace('/[^a-z0-9]/', '', strtolower((string) ($biz['city'] ?? '')));

        $bases = array_filter([$name, $name . $trade, $name . $city, $trade . $name, $city . $trade]);
        $out = [];
        foreach ($bases as $b) {
            foreach (['.com', '.co', '.net'] as $t) {
                if ($b !== '') {
                    $out[] = $b . $t;
                }
            }
        }
        return array_slice(array_values(array_unique($out)), 0, 15);
    }
}
