<?php

namespace SoftminalB2B\Import;

if (!defined('ABSPATH')) {
    exit;
}

use SoftminalB2B\Install;
use SoftminalB2B\Models\BogoRule;
use SoftminalB2B\Models\Group;
use SoftminalB2B\Models\PriceRule;
use SoftminalB2B\Models\QuantityRule;
use SoftminalB2B\Registration\Applicant;
use SoftminalB2B\Services\Settings;

/**
 * Base for incumbent importers (FEATURES H8). Each concrete importer only reads
 * its source and normalises it to the common shape below; all the writing —
 * idempotent, dry-run-aware — lives here, once, so both importers behave
 * identically and can never disagree about how a group or rule is created.
 *
 * The source is never written to (H8: "source-plugin data untouched"). The only
 * state we keep is a source→target id map, stored in an option, which makes a
 * re-run update rather than duplicate ("import is idempotent, safe to re-run").
 *
 * Normalised shape returned by collect():
 *   [
 *     'groups'   => [ ['source_id'=>string, 'name'=>string], ... ],
 *     'users'    => [ ['user_id'=>int, 'source_group_id'=>string, 'status'=>string], ... ],
 *     'rules'    => [ [
 *          'source_key'       => string,        // stable per-rule id for idempotency
 *          'source_group_id'  => string|null,   // '*' = all groups; null = customer/global
 *          'customer_id'      => int|null,      // for customer-scoped rules
 *          'scope'            => string,        // PriceRule scope
 *          'object_id'        => int,
 *          'type'             => string,        // PriceRule type
 *          'value'            => float,
 *          'min_qty'          => int,
 *       ], ... ],
 *     'warnings' => [ string, ... ],
 *   ]
 */
abstract class SourceImporter
{
    private const MAP_OPTION = 'import_map';

    /** @var array<string, array<string, array<string, int>>>|null */
    private static $map = null;

    abstract public function slug(): string;

    abstract public function label(): string;

    /**
     * Whether the source plugin's data is present to import (checked without the
     * plugin needing to be active — we read its stored data, not its code).
     */
    abstract public function isPresent(): bool;

    /**
     * @return array<string, mixed>
     */
    abstract public function collect(): array;

    /**
     * Preview (dry run) — collect and count, write nothing.
     */
    public function preview(): ImportReport
    {
        return $this->apply($this->collect(), false);
    }

    /**
     * Import for real.
     */
    public function run(): ImportReport
    {
        $report = $this->apply($this->collect(), true);

        // Prices changed wholesale; drop caches so the store reflects the import.
        do_action('smb2b_pricing_data_changed');

        return $report;
    }

    /**
     * @param array<string, mixed> $data
     */
    protected function apply(array $data, bool $commit): ImportReport
    {
        $report = new ImportReport();
        $report->committed = $commit;

        foreach ((array) ($data['warnings'] ?? []) as $warning) {
            $report->warn((string) $warning);
        }

        $groupIdBySource = $this->applyGroups((array) ($data['groups'] ?? []), $commit, $report);
        $this->applyUsers((array) ($data['users'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyRules((array) ($data['rules'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyQuantityRules((array) ($data['quantity_rules'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyBogoRules((array) ($data['bogo_rules'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyTaxExemptGroups((array) ($data['tax_exempt_groups'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyVisibility((array) ($data['visibility'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyMethodGating((array) ($data['method_gating'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyCompanies((array) ($data['companies'] ?? []), $groupIdBySource, $commit, $report);
        $this->applyQuotes((array) ($data['quotes'] ?? []), $commit, $report);

        return $report;
    }

    /**
     * @param array<int, array{source_id:string, name:string}> $groups
     * @return array<string, int> source_id => our group id
     */
    private function applyGroups(array $groups, bool $commit, ImportReport $report): array
    {
        $out = [];

        foreach ($groups as $group) {
            $sourceId = (string) $group['source_id'];
            $existing = $this->mappedId('groups', $sourceId);

            if ($existing && Group::find($existing)) {
                $out[$sourceId] = $existing;
                $report->skipped('groups');
                continue;
            }

            if (!$commit) {
                $out[$sourceId] = 0; // placeholder so rules can still be previewed
                $report->created('groups');
                continue;
            }

            $model = Group::create([
                'name'     => (string) $group['name'],
                'slug'     => Group::uniqueSlug((string) $group['name']),
                'settings' => ['_import_source' => $this->slug() . ':' . $sourceId],
            ]);

            $out[$sourceId] = (int) $model->id;
            $this->remember('groups', $sourceId, (int) $model->id);
            $report->created('groups');
        }

        return $out;
    }

    /**
     * @param array<int, array{user_id:int, source_group_id:string, status:string}> $users
     * @param array<string, int>                                                    $groupIdBySource
     */
    private function applyUsers(array $users, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        foreach ($users as $user) {
            $userId  = (int) $user['user_id'];
            $groupId = $groupIdBySource[(string) $user['source_group_id']] ?? 0;

            if ($userId <= 0 || !get_userdata($userId)) {
                $report->skipped('users');
                continue;
            }

            if (!$commit) {
                $report->created('users');
                continue;
            }

            if ($groupId > 0) {
                Group::assignUser($userId, $groupId);
            }

            $status = ($user['status'] ?? 'approved') === 'pending'
                ? Applicant::STATUS_PENDING
                : Applicant::STATUS_APPROVED;
            update_user_meta($userId, Applicant::META_STATUS, $status);

            $report->created('users');
        }
    }

    /**
     * @param array<int, array<string, mixed>> $rules
     * @param array<string, int>               $groupIdBySource
     */
    private function applyRules(array $rules, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        foreach ($rules as $rule) {
            // Expand an "everyone" rule into one rule per imported group.
            $sourceGroup = $rule['source_group_id'] ?? null;

            if ($sourceGroup === '*') {
                foreach (array_keys($groupIdBySource) as $sid) {
                    $this->applyOneRule($rule, (string) $sid, $groupIdBySource, $commit, $report);
                }
                continue;
            }

            $this->applyOneRule($rule, $sourceGroup, $groupIdBySource, $commit, $report);
        }
    }

    /**
     * @param array<string, mixed> $rule
     * @param string|null          $sourceGroup
     * @param array<string, int>   $groupIdBySource
     */
    private function applyOneRule(array $rule, $sourceGroup, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        $groupId = null;

        if ($sourceGroup !== null && $sourceGroup !== '') {
            $groupId = $groupIdBySource[(string) $sourceGroup] ?? 0;

            if ($groupId === 0 && $commit) {
                // Group wasn't imported (e.g. skipped) — can't attach the rule.
                $report->skipped('rules');
                return;
            }
        }

        $mapKey   = (string) $rule['source_key'] . ($sourceGroup !== null ? ':' . $sourceGroup : '');
        $existing = $this->mappedId('rules', $mapKey);

        if ($existing && PriceRule::find($existing)) {
            if ($commit) {
                $model = PriceRule::find($existing);
                $model->type     = (string) $rule['type'];
                $model->value    = (float) $rule['value'];
                $model->min_qty  = max(1, (int) $rule['min_qty']);
                $model->save();
            }
            $report->updated('rules');
            return;
        }

        if (!$commit) {
            $report->created('rules');
            return;
        }

        $model = PriceRule::create([
            'group_id'  => $groupId ?: null,
            'scope'     => (string) $rule['scope'],
            'object_id' => (int) ($rule['customer_id'] ?? $rule['object_id'] ?? 0),
            'type'      => (string) $rule['type'],
            'value'     => (float) $rule['value'],
            'min_qty'   => max(1, (int) ($rule['min_qty'] ?? 1)),
            'active'    => 1,
        ]);

        $this->remember('rules', $mapKey, (int) $model->id);
        $report->created('rules');
    }

    // -------------------------------------------------- quantity rules (A5)

    /**
     * Order quantity constraints (min / max / step / order-value) imported into
     * our quantity_rules table. Mirrors applyRules: a source_group_id of '*'
     * fans the rule out to every imported group; null means "all customers".
     *
     * Normalised quantity-rule shape:
     *   [ 'source_key'=>string, 'source_group_id'=>string|'*'|null,
     *     'scope'=>string, 'object_id'=>int,
     *     'min_qty'=>?int, 'max_qty'=>?int, 'step_qty'=>?int, 'min_order_value'=>?float ]
     *
     * @param array<int, array<string, mixed>> $rules
     * @param array<string, int>               $groupIdBySource
     */
    private function applyQuantityRules(array $rules, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        foreach ($rules as $rule) {
            $sourceGroup = $rule['source_group_id'] ?? null;

            if ($sourceGroup === '*') {
                foreach (array_keys($groupIdBySource) as $sid) {
                    $this->applyOneQuantityRule($rule, (string) $sid, $groupIdBySource, $commit, $report);
                }
                continue;
            }

            $this->applyOneQuantityRule($rule, $sourceGroup, $groupIdBySource, $commit, $report);
        }
    }

    /**
     * @param array<string, mixed> $rule
     * @param string|null          $sourceGroup
     * @param array<string, int>   $groupIdBySource
     */
    private function applyOneQuantityRule(array $rule, $sourceGroup, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        $groupId = null;

        if ($sourceGroup !== null && $sourceGroup !== '') {
            $groupId = $groupIdBySource[(string) $sourceGroup] ?? 0;

            if ($groupId === 0 && $commit) {
                $report->skipped('quantity_rules');
                return;
            }
        }

        $intOrNull   = static fn ($v) => ($v === null || $v === '') ? null : (int) $v;
        $floatOrNull = static fn ($v) => ($v === null || $v === '') ? null : (float) $v;

        $mapKey   = (string) $rule['source_key'] . ($sourceGroup !== null ? ':' . $sourceGroup : '');
        $existing = $this->mappedId('quantity_rules', $mapKey);

        if ($existing && QuantityRule::find($existing)) {
            if ($commit) {
                $model                  = QuantityRule::find($existing);
                $model->min_qty         = $intOrNull($rule['min_qty'] ?? null);
                $model->max_qty         = $intOrNull($rule['max_qty'] ?? null);
                $model->step_qty        = $intOrNull($rule['step_qty'] ?? null);
                $model->min_order_value = $floatOrNull($rule['min_order_value'] ?? null);
                $model->save();
            }
            $report->updated('quantity_rules');
            return;
        }

        if (!$commit) {
            $report->created('quantity_rules');
            return;
        }

        $model = QuantityRule::create([
            'group_id'        => $groupId ?: null,
            'scope'           => (string) $rule['scope'],
            'object_id'       => (int) ($rule['object_id'] ?? 0),
            'min_qty'         => $intOrNull($rule['min_qty'] ?? null),
            'max_qty'         => $intOrNull($rule['max_qty'] ?? null),
            'step_qty'        => $intOrNull($rule['step_qty'] ?? null),
            'min_order_value' => $floatOrNull($rule['min_order_value'] ?? null),
            'active'          => 1,
        ]);

        $this->remember('quantity_rules', $mapKey, (int) $model->id);
        $report->created('quantity_rules');
    }

    // -------------------------------------------------- BOGO promotions

    /**
     * Buy-X-get-Y promotions → our bogo_rules. Mirrors applyRules: '*' fans out
     * to every imported group, null = all customers. Normalised shape:
     *   [ 'source_key'=>string, 'source_group_id'=>string|'*'|null,
     *     'title'=>string, 'buy_type'=>'product'|'category', 'buy_id'=>int,
     *     'buy_qty'=>int, 'reward_type'=>'free'|'percent'|'amount', 'reward_qty'=>int,
     *     'reward_value'=>float, 'reward_scope'=>'same'|'product'|'cheapest',
     *     'reward_product_id'=>int, 'repeat_reward'=>bool ]
     *
     * @param array<int, array<string, mixed>> $rules
     * @param array<string, int>               $groupIdBySource
     */
    private function applyBogoRules(array $rules, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        foreach ($rules as $rule) {
            $sourceGroup = $rule['source_group_id'] ?? null;

            if ($sourceGroup === '*') {
                foreach (array_keys($groupIdBySource) as $sid) {
                    $this->applyOneBogoRule($rule, (string) $sid, $groupIdBySource, $commit, $report);
                }
                continue;
            }

            $this->applyOneBogoRule($rule, $sourceGroup, $groupIdBySource, $commit, $report);
        }
    }

    /**
     * @param array<string, mixed> $rule
     * @param string|null          $sourceGroup
     * @param array<string, int>   $groupIdBySource
     */
    private function applyOneBogoRule(array $rule, $sourceGroup, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        $groupId = null;

        if ($sourceGroup !== null && $sourceGroup !== '') {
            $groupId = $groupIdBySource[(string) $sourceGroup] ?? 0;

            if ($groupId === 0 && $commit) {
                $report->skipped('bogo_rules');
                return;
            }
        }

        $mapKey   = (string) $rule['source_key'] . ($sourceGroup !== null ? ':' . $sourceGroup : '');
        $existing = $this->mappedId('bogo_rules', $mapKey);

        if ($existing && BogoRule::find($existing)) {
            $report->updated('bogo_rules');
            return;
        }

        if (!$commit) {
            $report->created('bogo_rules');
            return;
        }

        $model = BogoRule::create([
            'title'             => (string) ($rule['title'] ?? 'Imported promotion'),
            'buy_type'          => (string) $rule['buy_type'],
            'buy_id'            => (int) $rule['buy_id'],
            'buy_qty'           => max(1, (int) ($rule['buy_qty'] ?? 1)),
            'reward_type'       => (string) ($rule['reward_type'] ?? 'free'),
            'reward_qty'        => max(1, (int) ($rule['reward_qty'] ?? 1)),
            'reward_value'      => (float) ($rule['reward_value'] ?? 0),
            'reward_scope'      => (string) ($rule['reward_scope'] ?? 'same'),
            'reward_product_id' => (int) ($rule['reward_product_id'] ?? 0),
            'group_id'          => $groupId ?: null,
            'repeat_reward'     => !empty($rule['repeat_reward']) ? 1 : 0,
            'active'            => 1,
        ]);

        $this->remember('bogo_rules', $mapKey, (int) $model->id);
        $report->created('bogo_rules');
    }

    // -------------------------------------------------- tax exemption (C3)

    /**
     * Mark imported groups tax-exempt by unioning their ids into the Pro
     * `tax_exempt_groups` option (written through the core Settings service, so
     * the data lands now and activates when Pro is on — core owns all migrations).
     *
     * @param array<int, string> $sourceGroupIds source group ids to exempt
     * @param array<string, int> $groupIdBySource
     */
    private function applyTaxExemptGroups(array $sourceGroupIds, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        if (empty($sourceGroupIds)) {
            return;
        }

        $current = array_map('intval', (array) Settings::get('tax_exempt_groups', []));
        $changed = false;

        foreach (array_unique(array_map('strval', $sourceGroupIds)) as $sid) {
            if (!array_key_exists($sid, $groupIdBySource)) {
                continue; // group isn't being imported
            }

            if (!$commit) {
                $report->created('tax_exemptions');
                continue;
            }

            $gid = (int) $groupIdBySource[$sid];
            if ($gid <= 0) {
                $report->skipped('tax_exemptions');
                continue;
            }

            if (in_array($gid, $current, true)) {
                $report->skipped('tax_exemptions');
                continue;
            }

            $current[] = $gid;
            $changed   = true;
            $report->created('tax_exemptions');
        }

        if ($changed) {
            Settings::set('tax_exempt_groups', array_values(array_unique($current)));
        }
    }

    // -------------------------------------------------- quotes (E1-E3)

    /**
     * Merchant offers → quotes (+ line items) in the Pro-owned tables. One quote
     * per targeted user, status carried from the source. Conversation threads are
     * deliberately not migrated. Normalised shape:
     *   [ ['source_key'=>string, 'user_id'=>int, 'status'=>string,
     *      'items'=>[ ['product_id'=>int,'variation_id'=>?int,'quantity'=>int,'offered_price'=>float], ... ]] ]
     *
     * @param array<int, array<string, mixed>> $quotes
     */
    private function applyQuotes(array $quotes, bool $commit, ImportReport $report): void
    {
        if (empty($quotes)) {
            return;
        }

        global $wpdb;

        $quotesTable = Install::tableName('quotes');
        $itemsTable  = Install::tableName('quote_items');
        $now         = current_time('mysql');

        foreach ($quotes as $quote) {
            $sourceKey = (string) ($quote['source_key'] ?? '');
            $userId    = (int) ($quote['user_id'] ?? 0);
            $items     = array_values(array_filter((array) ($quote['items'] ?? []), 'is_array'));

            if ($sourceKey === '' || $userId <= 0 || empty($items) || !get_userdata($userId)) {
                continue;
            }

            if ($this->mappedId('quotes', $sourceKey)) {
                $report->skipped('quotes');
                continue;
            }

            if (!$commit) {
                $report->created('quotes');
                continue;
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($quotesTable, [
                'user_id'    => $userId,
                'status'     => (string) ($quote['status'] ?? 'offered'),
                'created_at' => $now,
                'updated_at' => $now,
            ]);

            $quoteId = (int) $wpdb->insert_id;
            if ($quoteId <= 0) {
                $report->skipped('quotes');
                continue;
            }

            $this->remember('quotes', $sourceKey, $quoteId);

            foreach ($items as $item) {
                $productId = (int) ($item['product_id'] ?? 0);
                if ($productId <= 0) {
                    continue;
                }

                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->insert($itemsTable, [
                    'quote_id'      => $quoteId,
                    'product_id'    => $productId,
                    'variation_id'  => ($item['variation_id'] ?? null) ?: null,
                    'quantity'      => max(1, (int) ($item['quantity'] ?? 1)),
                    'offered_price' => isset($item['offered_price']) ? (float) $item['offered_price'] : null,
                ]);
            }

            $report->created('quotes');
        }
    }

    // -------------------------------------------------- method gating (F2)

    /**
     * Per-group payment/shipping allow-lists → the Pro `method_gating` option
     * (keyed by group id: {payments:[ids], shipping:[ids]}). An allow-list means
     * "restrict this group to exactly these"; null for a kind = leave unrestricted.
     * Normalised shape:
     *   [ ['source_group_id'=>string, 'payments'=>?array, 'shipping'=>?array], ... ]
     *
     * @param array<int, array<string, mixed>> $gating
     * @param array<string, int>               $groupIdBySource
     */
    private function applyMethodGating(array $gating, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        if (empty($gating)) {
            return;
        }

        foreach ($gating as $entry) {
            $sg = (string) ($entry['source_group_id'] ?? '');

            if ($sg === '' || !array_key_exists($sg, $groupIdBySource)) {
                continue;
            }

            if (!$commit) {
                $report->created('method_gating');
                continue;
            }

            $gid = (int) $groupIdBySource[$sg];
            if ($gid <= 0) {
                $report->skipped('method_gating');
                continue;
            }

            $map   = is_array(Settings::get('method_gating', [])) ? Settings::get('method_gating', []) : [];
            $group = is_array($map[$gid] ?? null) ? $map[$gid] : [];

            if (array_key_exists('payments', $entry) && is_array($entry['payments'])) {
                $group['payments'] = array_values(array_map('strval', $entry['payments']));
            }
            if (array_key_exists('shipping', $entry) && is_array($entry['shipping'])) {
                $group['shipping'] = array_values(array_map('strval', $entry['shipping']));
            }

            if (empty($group)) {
                $report->skipped('method_gating');
                continue;
            }

            $map[$gid] = $group;
            Settings::set('method_gating', $map);
            $report->created('method_gating');
        }
    }

    // -------------------------------------------------- companies (D1/D2)

    /**
     * Company accounts + members. The Company models are Pro, but core owns the
     * tables (Install), so the importer writes them directly with $wpdb — the
     * data lands now and the Companies feature reads it when Pro is on.
     * Normalised shape:
     *   [ 'source_key'=>string, 'name'=>string, 'source_group_id'=>string|null,
     *     'vat_number'=>?string,
     *     'members'=>[ ['user_id'=>int, 'role'=>'admin'|'approver'|'buyer'], ... ] ]
     *
     * @param array<int, array<string, mixed>> $companies
     * @param array<string, int>               $groupIdBySource
     */
    private function applyCompanies(array $companies, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        if (empty($companies)) {
            return;
        }

        global $wpdb;

        $companiesTable = Install::tableName('companies');
        $membersTable   = Install::tableName('company_users');
        $now            = current_time('mysql');
        $validRoles     = ['admin', 'approver', 'buyer'];

        foreach ($companies as $company) {
            $sourceKey = (string) ($company['source_key'] ?? '');

            if ($sourceKey === '' || $this->mappedId('companies', $sourceKey)) {
                // Already imported (or unusable) — don't duplicate.
                if ($sourceKey !== '') {
                    $report->skipped('companies');
                }
                continue;
            }

            $hasCredit = is_array($company['credit'] ?? null) && (float) ($company['credit']['limit'] ?? 0) > 0;

            if (!$commit) {
                $report->created('companies');
                if ($hasCredit) {
                    $report->created('credit_accounts');
                }
                continue;
            }

            $sg      = $company['source_group_id'] ?? null;
            $groupId = ($sg !== null && isset($groupIdBySource[(string) $sg])) ? ((int) $groupIdBySource[(string) $sg] ?: null) : null;

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($companiesTable, [
                'name'       => (string) ($company['name'] ?? 'Company'),
                'vat_number' => ($company['vat_number'] ?? null) ?: null,
                'vat_status' => 'unchecked',
                'group_id'   => $groupId,
                'billing'    => null,
                'created_at' => $now,
                'updated_at' => $now,
            ]);

            $companyId = (int) $wpdb->insert_id;
            if ($companyId <= 0) {
                $report->skipped('companies');
                continue;
            }

            $this->remember('companies', $sourceKey, $companyId);

            foreach ((array) ($company['members'] ?? []) as $member) {
                $userId = (int) ($member['user_id'] ?? 0);
                if ($userId <= 0 || !get_userdata($userId)) {
                    continue;
                }

                $role = in_array($member['role'] ?? 'buyer', $validRoles, true) ? (string) $member['role'] : 'buyer';

                // UNIQUE(company_id, user_id) makes this idempotent. Table name is a
                // trusted code-defined identifier (wpdb prefix + constant); it cannot
                // be a prepare() placeholder, and all values are prepared.
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
                $wpdb->query($wpdb->prepare(
                    "INSERT IGNORE INTO {$membersTable} (company_id, user_id, role, created_at) VALUES (%d, %d, %s, %s)",
                    $companyId,
                    $userId,
                    $role,
                    $now
                ));
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
            }

            $report->created('companies');

            if ($hasCredit) {
                $this->applyCompanyCredit($companyId, (array) $company['credit'], $now, $report);
            }
        }
    }

    /**
     * A credit account for an imported company. The balance is derived from the
     * ledger (SUM(amount), positive = owed), so a consumed balance is written as
     * one opening 'adjustment' transaction rather than stored on the account.
     *
     * @param array<string, mixed> $credit  ['limit'=>float, 'consumed'=>float, 'terms_days'=>int]
     */
    private function applyCompanyCredit(int $companyId, array $credit, string $now, ImportReport $report): void
    {
        global $wpdb;

        $accountsTable     = Install::tableName('credit_accounts');
        $transactionsTable = Install::tableName('credit_transactions');

        // UNIQUE(company_id): don't create a second account for the same company.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        $existing = (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM {$accountsTable} WHERE company_id = %d", $companyId));
        if ($existing > 0) {
            $report->skipped('credit_accounts');
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->insert($accountsTable, [
            'company_id'   => $companyId,
            'credit_limit' => (float) ($credit['limit'] ?? 0),
            'terms_days'   => max(0, (int) ($credit['terms_days'] ?? 30)),
            'created_at'   => $now,
        ]);

        $accountId = (int) $wpdb->insert_id;
        if ($accountId <= 0) {
            $report->skipped('credit_accounts');
            return;
        }

        $consumed = (float) ($credit['consumed'] ?? 0);
        if ($consumed > 0) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->insert($transactionsTable, [
                'account_id' => $accountId,
                'type'       => 'adjustment',
                'amount'     => $consumed, // positive = the company owes this
                'note'       => __('Opening balance imported from the previous plugin', 'softminal-b2b-wholesale-for-woocommerce'),
                'created_at' => $now,
            ]);
        }

        $report->created('credit_accounts');
    }

    // -------------------------------------------------- catalog visibility (B2/B3)

    /**
     * Catalog visibility, written through the core Settings service (keys match
     * Pro's Catalog\Visibility + core hide-prices). Normalised shape:
     *   [ 'full_private' => bool,          // whole store visible only to approved B2B
     *     'hide_prices_guests' => bool,    // hide prices from non-B2B viewers
     *     'group_hidden_categories' => [ source_group_id => [category_id, ...] ] ]
     *
     * @param array<string, mixed> $v
     * @param array<string, int>   $groupIdBySource
     */
    private function applyVisibility(array $v, array $groupIdBySource, bool $commit, ImportReport $report): void
    {
        if (empty($v)) {
            return;
        }

        if (!empty($v['full_private'])) {
            $this->applyFlagSetting(
                'catalog_private_full',
                'yes',
                $commit,
                $report,
                __('Your whole catalogue is now private — visible only to approved B2B customers — matching the source plugin’s B2B-only mode. If that isn’t intended, turn it off under Settings → Access.', 'softminal-b2b-wholesale-for-woocommerce')
            );
        }

        if (!empty($v['hide_prices_guests'])) {
            $this->applyFlagSetting(
                'hide_prices_for_guests',
                'yes',
                $commit,
                $report,
                __('Prices are now hidden from logged-out visitors, matching the source plugin. Adjust this under Settings if you’d rather show them.', 'softminal-b2b-wholesale-for-woocommerce')
            );
        }

        foreach ((array) ($v['group_hidden_categories'] ?? []) as $sid => $categoryIds) {
            if (!array_key_exists((string) $sid, $groupIdBySource)) {
                continue;
            }

            if (!$commit) {
                $report->created('visibility');
                continue;
            }

            $gid = (int) $groupIdBySource[(string) $sid];
            if ($gid <= 0) {
                $report->skipped('visibility');
                continue;
            }

            $this->applyGroupHiddenCategories($gid, array_map('intval', (array) $categoryIds), $report);
        }
    }

    private function applyFlagSetting(string $key, string $value, bool $commit, ImportReport $report, ?string $notice = null): void
    {
        if (Settings::get($key, '') === $value) {
            $report->skipped('visibility');
            return;
        }

        if ($commit) {
            Settings::set($key, $value);
        }

        $report->created('visibility');

        if ($notice !== null) {
            $report->notice($notice);
        }
    }

    /**
     * @param array<int, int> $categoryIds
     */
    private function applyGroupHiddenCategories(int $groupId, array $categoryIds, ImportReport $report): void
    {
        $categoryIds = array_values(array_filter($categoryIds, static fn ($id) => $id > 0));
        if (empty($categoryIds)) {
            return;
        }

        $map      = Settings::get('catalog_group_hidden', []);
        $map      = is_array($map) ? $map : [];
        $existing = array_map('intval', (array) ($map[$groupId] ?? []));
        $merged   = array_values(array_unique(array_merge($existing, $categoryIds)));

        if ($merged === $existing) {
            $report->skipped('visibility');
            return;
        }

        $map[$groupId] = $merged;
        Settings::set('catalog_group_hidden', $map);
        $report->created('visibility');
    }

    // ------------------------------------------------------------- id mapping

    protected function mappedId(string $entity, string $sourceId): int
    {
        $map = self::map();

        return (int) ($map[$this->slug()][$entity][$sourceId] ?? 0);
    }

    protected function remember(string $entity, string $sourceId, int $ourId): void
    {
        $map = self::map();
        $map[$this->slug()][$entity][$sourceId] = $ourId;
        self::$map = $map;
        Settings::set(self::MAP_OPTION, $map);
    }

    /**
     * @return array<string, array<string, array<string, int>>>
     */
    private static function map(): array
    {
        if (self::$map === null) {
            $raw = Settings::get(self::MAP_OPTION, []);
            self::$map = is_array($raw) ? $raw : [];
        }

        return self::$map;
    }

    public static function flushMap(): void
    {
        self::$map = null;
    }
}
