<?php
/**
 * ShippingManager — Main shipping service.
 *
 * Responsibilities:
 *   1. Given a cart + shipping address, return all available shipping methods
 *      with their calculated costs (get_available_rates).
 *   2. Given a specific method ID + cart + address, return that method's cost
 *      (get_cost_for_method) — this is the function wired to the checkout filter.
 *   3. CRUD wrapper for shipping methods (delegating to ShippingRate for rate
 *      rows so callers only need to talk to ShippingManager).
 *
 * Zone-to-method flow:
 *   address → ShippingZone::get_matching_zone_ids()
 *           → tc_shipping_rates rows for those zones
 *           → ShippingRate::calculate() per rate row
 *           → deduplicate (most-specific zone wins per method)
 *
 * @package TitanCart\Modules\Shipping
 */

namespace TitanCart\Modules\Shipping;

use TitanCart\DB\Database;

defined( 'ABSPATH' ) || exit;

class ShippingManager {

    private Database     $db;
    private ShippingZone $zone_repo;
    private ShippingRate $rate_repo;

    public function __construct() {
        $this->db        = titancart_db();
        $this->zone_repo = new ShippingZone();
        $this->rate_repo = new ShippingRate();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PUBLIC RATE LOOKUP
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return all available shipping methods (with costs) for the given cart
     * and shipping address.
     *
     * Each element of the returned array:
     *   {method_id, rate_id, zone_id, rate_type, name, description, cost}
     *
     * Results are sorted cheapest-first.  When the same method appears in
     * multiple matching zones, only the most-specific zone's rate is kept.
     *
     * @param  array $cart     Cart array from CartManager (must include 'items', 'subtotal').
     * @param  array $address  {country, state, postcode, ...}
     * @return array[]
     */
    public function get_available_rates( array $cart, array $address ): array {
        // Cart totals feed the `titancart_shipping_rates` filter on EVERY exit path,
        // including the empty ones, so an extension provider (e.g. live Printful
        // shipping rates) can inject a method even when core has NO zones/rates
        // configured — a pure print-on-demand store. Built up-front for that reason.
        $cart_totals = $this->build_cart_totals( $cart );

        $zone_ids = $this->zone_repo->get_matching_zone_ids( $address );

        if ( empty( $zone_ids ) ) {
            // No matching zone, but realtime carriers (USPS/UPS/FedEx) and extension
            // providers may still add methods — surface them via finalize_rates().
            return $this->finalize_rates( [], $cart, $address, $cart_totals );
        }

        $lang = $this->current_language();

        // Fetch all active rates for matching zones in one query.
        $rates = $this->db->getArray(
            "SELECT r.*,
                    m.status   AS method_status,
                    m.min_weight,
                    m.max_weight
             FROM {$this->db->table('shipping_rates')} r
             INNER JOIN {$this->db->table('shipping_methods')} m ON m.id = r.method_id
             WHERE r.zone_id IN (?a)
               AND m.status  = 'active'
             ORDER BY r.zone_id, r.position ASC, r.id ASC",
            $zone_ids
        );

        if ( empty( $rates ) ) {
            // No zone rate rows, but realtime carriers + extensions may still add
            // methods — surface them via finalize_rates().
            return $this->finalize_rates( [], $cart, $address, $cart_totals );
        }

        // Batch-load all conditions for these rates.
        $rate_ids       = array_column( $rates, 'id' );
        $conditions_map = $this->load_conditions_batch( $rate_ids );

        // Batch-load translated names for all unique method IDs.
        $method_ids = array_unique( array_column( $rates, 'method_id' ) );
        $names      = $this->get_method_names_batch( $method_ids, $lang );

        // Zone priority index: zone_id → specificity (0 = most specific).
        $zone_priority = array_flip( $zone_ids );

        // Build results — one entry per method_id, from the most-specific zone.
        // Multiple rates in the same zone for the same method are SUMMED
        // (e.g., base rate $0 + weight surcharge $7 = $7 total).
        $results = [];

        foreach ( $rates as $rate ) {
            $method_id = (int) $rate['method_id'];
            $zone_id   = (int) $rate['zone_id'];
            $rate_id   = (int) $rate['id'];

            // Enforce method-level weight constraints.
            if ( ! $this->passes_weight_check( $rate, $cart_totals ) ) {
                continue;
            }

            // Hydrate conditions from batch-loaded map.
            $rate['conditions'] = $conditions_map[ $rate_id ] ?? [];

            // Calculate cost; null means no matching band → skip.
            $cost = $this->rate_repo->calculate( $rate, $cart_totals );
            if ( $cost === null ) {
                continue;
            }

            $priority = $zone_priority[ $zone_id ] ?? PHP_INT_MAX;

            if ( ! isset( $results[ $method_id ] ) ) {
                // First rate for this method — store it.
                $results[ $method_id ] = [
                    'method_id'   => $method_id,
                    'rate_id'     => $rate_id,
                    'zone_id'     => $zone_id,
                    'rate_type'   => $rate['rate_type'],
                    /* translators: %d: shipping method ID (fallback label when the method has no name). */
                    'name'        => $names[ $method_id ]['name']        ?? sprintf( __( 'Shipping #%d', 'titancart-lite' ), $method_id ),
                    'description' => $names[ $method_id ]['description'] ?? '',
                    'cost'        => $cost,
                    '_priority'   => $priority,
                ];
            } elseif ( $priority < $results[ $method_id ]['_priority'] ) {
                // More-specific zone found — replace entirely with this zone's rate.
                $results[ $method_id ] = [
                    'method_id'   => $method_id,
                    'rate_id'     => $rate_id,
                    'zone_id'     => $zone_id,
                    'rate_type'   => $rate['rate_type'],
                    /* translators: %d: shipping method ID (fallback label when the method has no name). */
                    'name'        => $names[ $method_id ]['name']        ?? sprintf( __( 'Shipping #%d', 'titancart-lite' ), $method_id ),
                    'description' => $names[ $method_id ]['description'] ?? '',
                    'cost'        => $cost,
                    '_priority'   => $priority,
                ];
            } elseif ( $priority === $results[ $method_id ]['_priority'] ) {
                // Same zone — sum this rate's cost (base + surcharges).
                $results[ $method_id ]['cost'] += $cost;
            }
            // priority > existing → less-specific zone → skip.
        }

        // Strip internal _priority key. Cheapest-first sorting and the
        // `titancart_shipping_rates` filter both happen in finalize_rates(), AFTER
        // realtime carrier rates are merged in, so core + realtime options sort
        // together as one list.
        $output = array_values( $results );
        foreach ( $output as &$row ) {
            unset( $row['_priority'] );
        }
        unset( $row );

        return $this->finalize_rates( $output, $cart, $address, $cart_totals );
    }

    /**
     * Return the shipping cost for a specific method + cart + address.
     *
     * This is called by the `titancart_checkout_shipping_cost` filter.
     * Returns 0.0 if the method is unavailable for the given cart/address.
     *
     * @param  int    $method_id
     * @param  array  $cart
     * @param  array  $address
     * @param  string $service_code  Optional realtime carrier service (e.g. 'PRIORITY').
     *                               A realtime method surfaces one row per service, each
     *                               tagged with service_code; when set, that specific row
     *                               must match. Empty matches core (non-realtime) methods
     *                               exactly as before — backward-compatible.
     * @return float
     */
    public function get_cost_for_method( int $method_id, array $cart, array $address, string $service_code = '' ): float {
        if ( $method_id <= 0 ) {
            return 0.0;
        }

        // Parity fix (S330): the amount CHARGED must equal what the checkout page
        // DISPLAYS. get_available_rates() SUMS every applicable rate for a method
        // within its most-specific matching zone — e.g. a $5 flat base rate + a
        // $10 by-price band = $15 total — and BOTH storefront rate endpoints
        // (ShippingController::get_rates and CheckoutController::get_shipping_methods)
        // render that summed figure to the shopper. This method previously fetched
        // only ONE rate per zone (ORDER BY position ASC LIMIT 1) and returned it, so
        // a multi-rate method was CHARGED a single rate ($5) while the picker SHOWED
        // the sum ($15) — the receipt diverged from the picker by the dropped bands.
        // Delegating to the same code path makes divergence impossible and also
        // inherits its weight checks, most-specific-zone resolution, and the
        // `titancart_shipping_rates` filter. (No recursion: get_available_rates does
        // not call back into this method.)
        foreach ( $this->get_available_rates( $cart, $address ) as $rate ) {
            if ( (int) ( $rate['method_id'] ?? 0 ) !== $method_id ) {
                continue;
            }
            // Realtime service disambiguation: core rows carry no service_code, so an
            // empty requested service_code matches them (legacy behaviour). When a
            // service_code IS supplied, only the matching carrier service row counts —
            // this is what keeps the amount CHARGED equal to the specific service the
            // shopper picked, not merely the cheapest service on that method.
            $row_service = (string) ( $rate['service_code'] ?? '' );
            if ( $service_code !== '' && $row_service !== $service_code ) {
                continue;
            }
            return (float) ( $rate['cost'] ?? 0.0 );
        }

        return 0.0;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REALTIME MERGE (Layer A)  — S378 / KI-32
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Finalise a rate list: merge realtime carrier rates, merge free-shipping
     * methods, sort cheapest-first, and apply the `titancart_shipping_rates` filter.
     *
     * Called on EVERY exit path of get_available_rates() (including the empty-zone
     * and empty-rate early returns) so realtime carriers AND free-shipping methods
     * surface even for a store with no zones/rates configured. ★ That is load-bearing
     * for KI-92: a store whose ONLY method is Free Shipping has no rate rows at all,
     * so it hits one of those early returns — wiring the free seam anywhere else
     * would leave it broken in the very configuration most likely to use it.
     * Core, realtime and free rows are sorted as a single list, and the public
     * filter is applied last so extensions see all three.
     *
     * @param  array[] $output       Core (zone-based) rate rows, possibly empty.
     * @param  array   $cart
     * @param  array   $address
     * @param  array   $cart_totals  {subtotal, item_count, total_weight}
     * @return array[]
     */
    private function finalize_rates( array $output, array $cart, array $address, array $cart_totals ): array {
        $output = $this->merge_realtime_rates( $output, $cart, $address, $cart_totals );
        $output = $this->merge_free_rates( $output, $cart_totals );

        usort( $output, static fn( $a, $b ) => ( $a['cost'] ?? 0 ) <=> ( $b['cost'] ?? 0 ) );

        /**
         * Filter the available shipping rates before returning to the caller.
         *
         * @param array[] $output       Calculated rates (core + realtime + free).
         * @param array   $cart         Cart array.
         * @param array   $address      Shipping address.
         * @param array   $cart_totals  {subtotal, item_count, total_weight}
         */
        return apply_filters( 'titancart_shipping_rates', $output, $cart, $address, $cart_totals );
    }

    /**
     * Merge realtime carrier (USPS/UPS/FedEx) rates into the core rate list.
     *
     * The core get_available_rates() query INNER JOINs tc_shipping_rates, so a
     * carrier method (which has NO rate rows) is invisible to it — this is the
     * historical wiring gap (KI-32 Layer A) that left get_realtime_rates() with no
     * caller. Here we load active carrier methods directly and, for each, fetch live
     * rates and emit one picker row PER carrier service.
     *
     * Parity model: each row keeps the REAL base method_id (so "USPS Live Rates"
     * still resolves to a name on the receipt / admin order screen) and is
     * additionally tagged with a distinct `service_code`; the composed display name
     * is "<method name> — <service label>". get_cost_for_method() re-selects the
     * exact service by (method_id, service_code) so the charged amount matches the
     * picked service.
     *
     * Fail-OPEN: a carrier API/credential error skips that carrier only — it never
     * strips the rest of the checkout's shipping options. The per-carrier cap
     * (realtime_max_options) is NOT applied here; it is enforced at the admin
     * service multi-select as a running count.
     *
     * @param  array[] $output
     * @param  array   $cart
     * @param  array   $address
     * @param  array   $cart_totals
     * @return array[]
     */
    private function merge_realtime_rates( array $output, array $cart, array $address, array $cart_totals ): array {
        $methods = $this->db->getArray(
            "SELECT id, carrier, settings, min_weight, max_weight
             FROM {$this->db->table('shipping_methods')}
             WHERE status = 'active'
               AND carrier IN ('usps','ups','fedex')"
        );

        // Fast path: no realtime methods configured → nothing to merge (avoids a
        // language/name batch load on the common flat-rate-only store).
        if ( empty( $methods ) ) {
            return $output;
        }

        $lang  = $this->current_language();
        $names = $this->get_method_names_batch( array_map( 'intval', array_column( $methods, 'id' ) ), $lang );

        foreach ( $methods as $method ) {
            $method_id = (int) $method['id'];
            $carrier   = (string) $method['carrier'];

            // Method-level weight window gate BEFORE any API call (reuses the same
            // check core applies to zone rates — min_weight/max_weight columns).
            if ( ! $this->passes_weight_check( $method, $cart_totals ) ) {
                continue;
            }

            // Fetch live rates — fail OPEN. get_realtime_rates() is itself Pro-gated
            // (returns [] on Lite) and applies the `titancart_realtime_carrier_rates`
            // filter, so this stays the single rate-production seam.
            try {
                $carrier_rates = $this->get_realtime_rates( $method_id, $cart, $address );
            } catch ( \Throwable $e ) {
                $this->log_realtime_error( $carrier, $e->getMessage() );
                continue;
            }

            if ( empty( $carrier_rates ) ) {
                continue;
            }

            $settings    = $this->decode_settings( $method['settings'] );
            $selected    = $this->selected_service_codes( $settings );
            /* translators: %d: shipping method ID (fallback label when the method has no name). */
            $method_name = $names[ $method_id ]['name'] ?? sprintf( __( 'Shipping #%d', 'titancart-lite' ), $method_id );

            foreach ( $carrier_rates as $cr ) {
                $service_code = (string) ( $cr['service_code'] ?? '' );

                // Merchant service selection: settings.service_codes[] (multi-select).
                // Empty selection = expose ALL services the carrier returned.
                if ( ! empty( $selected ) && ! in_array( $service_code, $selected, true ) ) {
                    continue;
                }

                $label = (string) ( $cr['label'] ?? $service_code );

                $output[] = [
                    'method_id'     => $method_id,   // REAL base id — name resolves downstream
                    'rate_id'       => 0,            // no tc_shipping_rates row backs a live rate
                    'zone_id'       => 0,
                    'rate_type'     => 'realtime',
                    'carrier'       => $carrier,
                    'service_code'  => $service_code,
                    'name'          => '' !== $label ? $method_name . ' — ' . $label : $method_name,
                    'description'   => (string) ( $cr['delivery_text'] ?? '' ),
                    'cost'          => (float) ( $cr['rate'] ?? 0 ),
                    'currency'      => (string) ( $cr['currency'] ?? 'USD' ),
                    'delivery_days' => $cr['delivery_days'] ?? null,
                ];
            }
        }

        return $output;
    }

    /**
     * Merge free-shipping methods (carrier = 'free') into the rate list.  [KI-92]
     *
     * Same wiring gap as KI-32 Layer A, one carrier further on. The core
     * get_available_rates() query INNER JOINs tc_shipping_rates, so a method with NO
     * rate rows is invisible to it. A `free` method never has rate rows — there is
     * nothing for a merchant to enter — so before this seam existed it could never
     * appear in the picker on ANY store, while the carrier remained selectable in the
     * admin ("Free shipping"). `flat`/Manual only ever worked because it produces real
     * zone rate rows, and a $0 price there is genuinely free — the documented
     * workaround. NOTE `table_rate` is NOT affected and deliberately gets no seam
     * here: it produces ordinary by_price/by_weight/by_items rate rows through the
     * same Rates tab, so the INNER JOIN already finds it (verified at source S421 —
     * KI-89's lesson applied: the sibling was audited, not bulk-fixed).
     *
     * Deliberately NOT zone-gated (Sean's settled call, S420): an active Free
     * Shipping method is offered wherever it is active, at $0, full stop. Zone-
     * restricted free shipping stays a merchant task via Manual + a $0 rate row.
     * The method-level min/max weight window IS still honoured, via the same
     * passes_weight_check() the core and realtime paths use — a "free under 5lb"
     * method must stay honest.
     *
     * ⚠️ Dedupe guard: the admin exposes the "Shipping Time & Rates" tab for every
     * non-realtime carrier, so a merchant CAN attach rate rows to a free method. Such
     * a method is already surfaced by the core query, and emitting a second row here
     * would double it in the picker. Methods already present in $output are skipped —
     * the core (zone-resolved) row wins.
     *
     * @param  array[] $output
     * @param  array   $cart_totals  {subtotal, item_count, total_weight}
     * @return array[]
     */
    private function merge_free_rates( array $output, array $cart_totals ): array {
        $methods = $this->db->getArray(
            "SELECT id, min_weight, max_weight
             FROM {$this->db->table('shipping_methods')}
             WHERE status = 'active'
               AND carrier = 'free'"
        );

        // Fast path: no free methods configured → nothing to merge (avoids a
        // language/name batch load on the common store without one).
        if ( empty( $methods ) ) {
            return $output;
        }

        // Methods the core zone query already surfaced — never emit twice.
        $already = [];
        foreach ( $output as $existing_row ) {
            $already[ (int) ( $existing_row['method_id'] ?? 0 ) ] = true;
        }

        $lang  = $this->current_language();
        $names = $this->get_method_names_batch( array_map( 'intval', array_column( $methods, 'id' ) ), $lang );

        foreach ( $methods as $method ) {
            $method_id = (int) $method['id'];

            if ( isset( $already[ $method_id ] ) ) {
                continue;
            }

            // Method-level weight window gate (min_weight/max_weight columns).
            if ( ! $this->passes_weight_check( $method, $cart_totals ) ) {
                continue;
            }

            $output[] = [
                'method_id'   => $method_id,
                'rate_id'     => 0,      // no tc_shipping_rates row backs a free method
                'zone_id'     => 0,      // not zone-gated by design
                'rate_type'   => 'free',
                'carrier'     => 'free',
                /* translators: %d: shipping method ID (fallback label when the method has no name). */
                'name'        => $names[ $method_id ]['name']        ?? sprintf( __( 'Shipping #%d', 'titancart-lite' ), $method_id ),
                'description' => $names[ $method_id ]['description'] ?? '',
                'cost'        => 0.0,
            ];
        }

        return $output;
    }

    /**
     * Resolve a carrier method's selected service codes from its settings.
     *
     * Supports the S351 multi-select (`service_codes` array). A legacy single
     * `service_code` string coerces to a one-element array. Empty result means "no
     * restriction" → expose every service the carrier returns.
     *
     * @param  array $settings
     * @return string[]
     */
    private function selected_service_codes( array $settings ): array {
        if ( ! empty( $settings['service_codes'] ) && is_array( $settings['service_codes'] ) ) {
            return array_values( array_filter( array_map( 'strval', $settings['service_codes'] ), static fn( $s ) => $s !== '' ) );
        }
        if ( ! empty( $settings['service_code'] ) ) {
            return [ (string) $settings['service_code'] ];
        }
        return [];
    }

    /**
     * Log a realtime carrier error (debug builds only).
     *
     * @param  string $carrier
     * @param  string $message
     * @return void
     */
    private function log_realtime_error( string $carrier, string $message ): void {
        if ( defined( 'TITANCART_DEBUG' ) && TITANCART_DEBUG ) {
            titancart_log( sprintf( '[TitanCart] realtime %s rate error: %s', $carrier, $message ) );
        }
    }

    // ══════════════════════════════════════════════════════════════════════════
    // METHOD CRUD
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return all shipping methods with translated names.
     *
     * @return array[]
     */
    public function get_all_methods(): array {
        $methods = $this->db->getArray(
            "SELECT * FROM {$this->db->table('shipping_methods')}
             ORDER BY position ASC, id ASC"
        );

        $lang = $this->current_language();

        foreach ( $methods as &$method ) {
            $trans = $this->get_method_translation( (int) $method['id'], $lang );
            $method['name']        = $trans['name']        ?? '';
            $method['description'] = $trans['description'] ?? '';
            $method['settings']    = $this->decode_settings( $method['settings'] );
        }
        unset( $method );

        return $methods;
    }

    /**
     * Return a single method with translations and rates, or null.
     *
     * @param  int $id
     * @return array|null
     */
    public function get_method_by_id( int $id ): ?array {
        $method = $this->db->getRow(
            "SELECT * FROM {$this->db->table('shipping_methods')} WHERE id = ?i LIMIT 1",
            $id
        );

        if ( ! $method ) {
            return null;
        }

        $lang = $this->current_language();
        $trans = $this->get_method_translation( $id, $lang );

        $method['name']        = $trans['name']        ?? '';
        $method['description'] = $trans['description'] ?? '';
        $method['settings']    = $this->decode_settings( $method['settings'] );
        $method['rates']       = $this->rate_repo->get_for_method( $id );

        return $method;
    }

    /**
     * Create a new shipping method.
     *
     * @param  array   $data          {carrier?, position?, status?, min_weight?, max_weight?, settings?, tax_class_id?}
     * @param  array[] $translations  { 'en' => ['name' => ..., 'description' => ...], ... }
     * @return int  New method ID.
     */
    public function create_method( array $data, array $translations = [] ): int {
        $settings = isset( $data['settings'] ) && is_array( $data['settings'] )
            ? wp_json_encode( $data['settings'] )
            : ( $data['settings'] ?? null );

        $this->db->query(
            "INSERT INTO {$this->db->table('shipping_methods')}
             (carrier, position, tax_class_id, min_weight, max_weight, settings, status)
             VALUES (?s, ?i, ?s, ?s, ?s, ?s, ?s)",
            $data['carrier']     ?? 'flat',
            (int) ( $data['position'] ?? 0 ),
            isset( $data['tax_class_id'] ) ? (string) (int) $data['tax_class_id'] : null,
            isset( $data['min_weight'] )   ? (string) $data['min_weight']          : null,
            isset( $data['max_weight'] )   ? (string) $data['max_weight']          : null,
            $settings,
            // KI-84: default on BOTH sides — the `in_array` tests the default, so an
            // absent/NULL key reaches the true-branch and must re-apply the `??`.
            in_array( $data['status'] ?? 'active', [ 'active', 'disabled' ], true ) ? ( $data['status'] ?? 'active' ) : 'active'
        );

        $method_id = $this->db->lastInsertId();

        foreach ( $translations as $lang_code => $t ) {
            $this->save_method_translation( $method_id, $lang_code, $t );
        }

        return $method_id;
    }

    /**
     * Update an existing shipping method.
     *
     * @param  int     $id
     * @param  array   $data
     * @param  array[] $translations  Optional. Keys are language codes.
     * @return bool
     */
    public function update_method( int $id, array $data, array $translations = [] ): bool {
        $allowed = [ 'carrier', 'position', 'tax_class_id', 'min_weight', 'max_weight', 'settings', 'status' ];
        $fields  = array_intersect_key( $data, array_flip( $allowed ) );

        if ( isset( $fields['settings'] ) && is_array( $fields['settings'] ) ) {
            $fields['settings'] = wp_json_encode( $fields['settings'] );
        }

        if ( ! empty( $fields ) ) {
            $this->db->query(
                "UPDATE {$this->db->table('shipping_methods')} SET ?u WHERE id = ?i",
                $fields,
                $id
            );
        }

        foreach ( $translations as $lang_code => $t ) {
            $this->save_method_translation( $id, $lang_code, $t );
        }

        return true;
    }

    /**
     * Soft-disable a shipping method.
     *
     * @param  int $id
     * @return bool
     */
    public function disable_method( int $id ): bool {
        $this->db->query(
            "UPDATE {$this->db->table('shipping_methods')} SET status = 'disabled' WHERE id = ?i",
            $id
        );
        return true;
    }

    /**
     * Hard-delete a shipping method and cascade to everything it owns.
     *
     * KI-97 (S424). Before this existed there was NO delete statement for a
     * shipping method anywhere in the codebase — DELETE /shipping/methods/{id}
     * mapped to disable_method() above, so mistaken and abandoned rows could
     * never be removed.
     *
     * Cascade (mirrors ShippingZone::delete()'s idiom deliberately):
     *   tc_shipping_rate_conditions  (via the method's rate ids)
     *   tc_shipping_rates            (method_id)
     *   tc_shipping_method_translations (method_id)
     *   tc_shipping_methods          (the row itself)
     *
     * ⚠️ Order matters: the condition rows are located THROUGH the rate rows,
     * so they must go first or their only handle is gone and they orphan
     * silently — nothing errors, the rows simply become unreachable.
     *
     * DELIBERATELY NOT CASCADED — historical references are left intact and are
     * safe because every consumer LEFT JOINs (measured S423, re-confirmed S424):
     *   tc_orders.shipping_method_id     — OrderManager::status_join_sql() L774-781
     *                                      LEFT JOINs; the method NAME survives
     *                                      independently in the tc_orders.shipping_info
     *                                      JSON snapshot written at checkout.
     *   tc_shipments.shipping_method_id  — ShipmentManager L96-102 LEFT JOINs with
     *                                      COALESCE(smt.name,''), so fulfilment
     *                                      degrades to an empty label, never drops a row.
     *
     * @param  int $id
     * @return bool  False if no such method; true once the cascade has run.
     */
    /**
     * Count what a shipping method currently owns / is referenced by.
     *
     * KI-97 (S424). Purely informational — it does NOT gate the delete, because
     * the safety question is already settled (every historical consumer LEFT
     * JOINs, and the order keeps its own name snapshot). It exists so the audit
     * log records the SIZE of what a delete removed, which is the one thing that
     * cannot be reconstructed afterwards.
     *
     * @param  int $id
     * @return array{rates:int, conditions:int, orders:int, shipments:int}
     */
    public function count_method_usage( int $id ): array {
        $rate_ids = $this->db->getColumn(
            "SELECT id FROM {$this->db->table('shipping_rates')} WHERE method_id = ?i",
            $id
        );

        $conditions = 0;
        if ( ! empty( $rate_ids ) ) {
            $conditions = (int) $this->db->getField(
                "SELECT COUNT(*) FROM {$this->db->table('shipping_rate_conditions')} WHERE rate_id IN (?a)",
                $rate_ids
            );
        }

        return [
            'rates'      => count( $rate_ids ),
            'conditions' => $conditions,
            'orders'     => (int) $this->db->getField(
                "SELECT COUNT(*) FROM {$this->db->table('orders')} WHERE shipping_method_id = ?i",
                $id
            ),
            'shipments'  => (int) $this->db->getField(
                "SELECT COUNT(*) FROM {$this->db->table('shipments')} WHERE shipping_method_id = ?i",
                $id
            ),
        ];
    }

    public function delete_method( int $id ): bool {
        if ( ! $this->get_method_by_id( $id ) ) {
            return false;
        }

        // Condition rows first — they are reachable only via their rate ids.
        $rate_ids = $this->db->getColumn(
            "SELECT id FROM {$this->db->table('shipping_rates')} WHERE method_id = ?i",
            $id
        );

        if ( ! empty( $rate_ids ) ) {
            $this->db->query(
                "DELETE FROM {$this->db->table('shipping_rate_conditions')} WHERE rate_id IN (?a)",
                $rate_ids
            );
        }

        // Rate rows.
        $this->db->query(
            "DELETE FROM {$this->db->table('shipping_rates')} WHERE method_id = ?i",
            $id
        );

        // Translations.
        $this->db->query(
            "DELETE FROM {$this->db->table('shipping_method_translations')} WHERE method_id = ?i",
            $id
        );

        // The method itself.
        $this->db->query(
            "DELETE FROM {$this->db->table('shipping_methods')} WHERE id = ?i",
            $id
        );

        return true;
    }

    /**
     * Expose the ShippingRate repository so the REST controller can use it
     * directly for rate-level operations.
     *
     * @return ShippingRate
     */
    public function rate_repo(): ShippingRate {
        return $this->rate_repo;
    }

    /**
     * Expose the ShippingZone repository for the REST controller.
     *
     * @return ShippingZone
     */
    public function zone_repo(): ShippingZone {
        return $this->zone_repo;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // DEFAULT SEEDING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Historically this seeded a "Worldwide" zone + a $0 flat-rate method on first
     * activation. That was a footgun: a store that never configured shipping would
     * silently charge $0 on physical orders (the seeded catch-all matched every address),
     * so nobody realised shipping was unconfigured. We no longer seed a phantom $0 method
     * — a store with no shipping configured now honestly surfaces "no shipping options
     * available" at checkout (CheckoutManager rejects a physical order with no available
     * method), which prompts the merchant to configure real rates.
     *
     * Kept as a no-op (still hooked on `titancart_loaded`) so existing call sites and the
     * activation flow are undisturbed. Existing installs that were seeded before this
     * change keep their seeded method until the merchant edits or removes it.
     */
    public function maybe_seed_defaults(): void {
        // Intentionally no default shipping method is seeded. See the docblock above.
    }

    // ══════════════════════════════════════════════════════════════════════════
    // INTERNAL HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Build normalised cart totals from a full cart array.
     *
     * `total_weight` is in POUNDS — the canonical internal unit (KI-93).
     * `$item['weight']` is normalised to lb once at cart hydration
     * (CartManager::weight_to_lb), so method min/max windows and by_weight rate
     * bands compare against pounds, matching the units the admin labels them with.
     * ⚠️ The realtime packaging builder converts lb → oz for the carrier contract;
     * it is the ONLY place a different unit is used. Keep that seam explicit —
     * before KI-93 the two readers disagreed silently.
     *
     * @param  array $cart
     * @return array {subtotal, item_count, total_weight}  total_weight in lb
     */
    private function build_cart_totals( array $cart ): array {
        $item_count   = 0;
        $total_weight = 0.0;

        foreach ( $cart['items'] ?? [] as $item ) {
            $qty           = (int) ( $item['quantity'] ?? 1 );
            $item_count   += $qty;
            $total_weight += (float) ( $item['weight'] ?? 0 ) * $qty;
        }

        return [
            'subtotal'     => (float) ( $cart['subtotal'] ?? 0 ),
            'item_count'   => $item_count,
            'total_weight' => $total_weight,
        ];
    }

    /**
     * Check that the cart's total weight falls within the method's min/max window.
     * Both min_weight and max_weight are nullable — null means unconstrained.
     *
     * @param  array $rate_or_method  Row containing min_weight and max_weight columns.
     * @param  array $cart_totals
     * @return bool
     */
    private function passes_weight_check( array $rate_or_method, array $cart_totals ): bool {
        $w   = $cart_totals['total_weight'];
        $min = $rate_or_method['min_weight'] !== null ? (float) $rate_or_method['min_weight'] : null;
        $max = $rate_or_method['max_weight'] !== null ? (float) $rate_or_method['max_weight'] : null;

        if ( $min !== null && $w < $min ) {
            return false;
        }
        if ( $max !== null && $w > $max ) {
            return false;
        }

        return true;
    }

    /**
     * Batch-load conditions for multiple rate IDs in a single query.
     *
     * @param  int[] $rate_ids
     * @return array  rate_id (int) → conditions[]
     */
    private function load_conditions_batch( array $rate_ids ): array {
        if ( empty( $rate_ids ) ) {
            return [];
        }

        $rows = $this->db->getArray(
            "SELECT * FROM {$this->db->table('shipping_rate_conditions')}
             WHERE rate_id IN (?a)
             ORDER BY rate_id ASC, from_value ASC",
            $rate_ids
        );

        $map = [];
        foreach ( $rows as $row ) {
            $map[ (int) $row['rate_id'] ][] = $row;
        }

        return $map;
    }

    /**
     * Batch-load translated names for multiple method IDs.
     *
     * Falls back to 'en' translations when the requested language is not found.
     *
     * @param  int[]  $method_ids
     * @param  string $lang
     * @return array  method_id (int) → {name, description}
     */
    private function get_method_names_batch( array $method_ids, string $lang ): array {
        if ( empty( $method_ids ) ) {
            return [];
        }

        $rows = $this->db->getArray(
            "SELECT * FROM {$this->db->table('shipping_method_translations')}
             WHERE method_id IN (?a)
               AND language_code IN (?a)
             ORDER BY method_id ASC,
                      CASE language_code WHEN ?s THEN 0 ELSE 1 END ASC",
            $method_ids,
            [ $lang, 'en' ],
            $lang
        );

        $map = [];
        foreach ( $rows as $row ) {
            $mid = (int) $row['method_id'];
            // Preferred lang overwrites fallback; skip if preferred already set.
            if ( ! isset( $map[ $mid ] ) || $row['language_code'] === $lang ) {
                $map[ $mid ] = [
                    'name'        => $row['name'],
                    'description' => $row['description'] ?? '',
                ];
            }
        }

        return $map;
    }

    /**
     * Load a single translation row; falls back to English.
     *
     * @param  int    $method_id
     * @param  string $lang
     * @return array
     */
    private function get_method_translation( int $method_id, string $lang ): array {
        $row = $this->db->getRow(
            "SELECT * FROM {$this->db->table('shipping_method_translations')}
             WHERE method_id = ?i AND language_code = ?s LIMIT 1",
            $method_id,
            $lang
        );

        if ( ! $row && $lang !== 'en' ) {
            $row = $this->db->getRow(
                "SELECT * FROM {$this->db->table('shipping_method_translations')}
                 WHERE method_id = ?i AND language_code = 'en' LIMIT 1",
                $method_id
            );
        }

        return $row ?? [];
    }

    /**
     * Upsert a translation row.
     *
     * @param  int    $method_id
     * @param  string $lang_code
     * @param  array  $t  {name, description?}
     */
    private function save_method_translation( int $method_id, string $lang_code, array $t ): void {
        $this->db->query(
            "INSERT INTO {$this->db->table('shipping_method_translations')}
             (method_id, language_code, name, description)
             VALUES (?i, ?s, ?s, ?s)
             ON DUPLICATE KEY UPDATE
               name        = VALUES(name),
               description = VALUES(description)",
            $method_id,
            $lang_code,
            sanitize_text_field( $t['name']        ?? '' ),
            sanitize_textarea_field( $t['description'] ?? '' )
        );
    }

    /**
     * Decode a JSON settings string into an array.
     *
     * @param  mixed $settings  Raw DB value.
     * @return array
     */
    private function decode_settings( mixed $settings ): array {
        if ( is_array( $settings ) ) {
            return $settings;
        }
        if ( is_string( $settings ) && $settings !== '' ) {
            return json_decode( $settings, true ) ?? [];
        }
        return [];
    }

    /**
     * Detect the current site language for translation lookups.
     * WPML-compatible.
     *
     * @return string  Two-character language code, e.g. 'en'.
     */
    private function current_language(): string {
        if ( function_exists( 'pll_current_language' ) ) {
            return pll_current_language() ?: 'en';
        }
        if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {
            return ICL_LANGUAGE_CODE;
        }
        return 'en';
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REALTIME CARRIER RATES
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return live carrier rates for a cart + shipping address.
     *
     * Called from the checkout API when the selected shipping method has
     * carrier = 'usps' | 'ups' | 'fedex'.
     *
     * The method's `settings` JSON column must contain the carrier credentials
     * and an origin_address sub-array.  Each cart item should have weight (oz)
     * and optionally dimensions { length, width, height } in inches.
     *
     * @param  int   $method_id  Shipping method ID (carrier type = realtime).
     * @param  array $cart       Cart array from CartManager.
     * @param  array $address    Destination {street, city, state, zip, country}.
     * @return array[]  Normalised rate rows: {service_code, label, rate, currency, delivery_days}
     */
    public function get_realtime_rates( int $method_id, array $cart, array $address ): array {
        return [];
    }

}
