<?php
/**
 * Shipping REST API Controller.
 *
 * Admin endpoints (require manage_options):
 *   GET    /titancart/v1/shipping/zones              List all zones
 *   POST   /titancart/v1/shipping/zones              Create a zone
 *   GET    /titancart/v1/shipping/zones/{id}         Get zone (with locations)
 *   PUT    /titancart/v1/shipping/zones/{id}         Update zone
 *   DELETE /titancart/v1/shipping/zones/{id}         Delete zone (cascades to rates)
 *
 *   POST   /titancart/v1/shipping/zones/{id}/locations          Add a location to zone
 *   DELETE /titancart/v1/shipping/zones/{id}/locations/{loc_id} Remove a location
 *
 *   GET    /titancart/v1/shipping/methods            List all methods
 *   POST   /titancart/v1/shipping/methods            Create a method
 *   GET    /titancart/v1/shipping/methods/{id}       Get method (with rates)
 *   PUT    /titancart/v1/shipping/methods/{id}       Update method
 *   DELETE /titancart/v1/shipping/methods/{id}       Disable method
 *
 *   POST   /titancart/v1/shipping/methods/{id}/test-rate  Rate dry run (KI-95)
 *
 *   GET    /titancart/v1/shipping/methods/{id}/rates  List rates for a method
 *   POST   /titancart/v1/shipping/methods/{id}/rates  Create a rate (with conditions)
 *   PUT    /titancart/v1/shipping/rates/{id}           Update rate fields
 *   DELETE /titancart/v1/shipping/rates/{id}           Delete rate + conditions
 *   PUT    /titancart/v1/shipping/rates/{id}/conditions Replace all conditions for a rate
 *
 * Public endpoint (no auth):
 *   POST   /titancart/v1/checkout/shipping-rates     Available methods for cart + address
 *
 * @package TitanCart\REST\Shipping
 */

namespace TitanCart\REST\Shipping;

use TitanCart\Modules\Shipping\ShippingManager;
use TitanCart\Modules\Shipping\ShippingZone;
use TitanCart\Modules\Shipping\ShippingRate;
use TitanCart\Core\LogService;
use TitanCart\Services\SettingsService;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

class ShippingController extends WP_REST_Controller {

    protected $namespace = 'titancart/v1';
    protected $rest_base = 'shipping';

    /**
     * Realtime carrier types — live-quoted rates at checkout (USPS / UPS / FedEx).
     *
     * These are the ONLY shipping features gated to Pro (the advanced-shipping
     * cap in Edition.php). Manual (flat), table rates (by weight / price /
     * items + conditions), and free
     * shipping are all Lite. The method's `carrier` column is the discriminator —
     * there is no separate `type` column (Schema: shipping_methods.carrier).
     */
    private const REALTIME_CARRIERS = [ 'usps', 'ups', 'fedex' ];

    /**
     * Default cap on the TOTAL number of realtime service options a merchant may
     * enable across all carriers (S351 design decision — total, not per-carrier).
     * A store SETTING (tc_settings section 'shipping', key 'realtime_max_options'),
     * overridable via /shipping/realtime-settings and enforced as a running count
     * at the admin service multi-select — never truncated at checkout.
     */
    private const DEFAULT_REALTIME_MAX_OPTIONS = 3;

    /**
     * Upper bound on the synthetic item count accepted by the admin rate dry run
     * (KI-95). Guards against a silly input (999999) driving a pointless loop and
     * against a `by_items` band being probed with a value no real cart reaches.
     */
    private const TEST_MAX_ITEM_LINES = 500;

    private ShippingManager $manager;
    private ShippingZone    $zone_repo;
    private ShippingRate    $rate_repo;

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

    // ══════════════════════════════════════════════════════════════════════════
    // ROUTE REGISTRATION
    // ══════════════════════════════════════════════════════════════════════════

    public function register_routes(): void {

        // ── Zones ─────────────────────────────────────────────────────────────

        register_rest_route( $this->namespace, "/{$this->rest_base}/zones", [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_zones' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_zone' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, "/{$this->rest_base}/zones/(?P<id>\d+)", [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_zone' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_zone' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_zone' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Zone locations ────────────────────────────────────────────────────

        register_rest_route( $this->namespace, "/{$this->rest_base}/zones/(?P<id>\d+)/locations", [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'add_location' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, "/{$this->rest_base}/zones/(?P<id>\d+)/locations/(?P<loc_id>\d+)", [
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'remove_location' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Methods ───────────────────────────────────────────────────────────

        register_rest_route( $this->namespace, "/{$this->rest_base}/methods", [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_methods' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, "/{$this->rest_base}/methods/(?P<id>\d+)", [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                // KI-97 (S424): was `disable_method` — a soft-disable behind a
                // button labelled "Del". Now a real cascading delete. The
                // Enable/Disable toggle is UNAFFECTED: the SPA drives it with
                // PUT {status}, never through this route.
                'callback'            => [ $this, 'delete_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Admin rate dry run (KI-95) ────────────────────────────────────────

        register_rest_route( $this->namespace, "/{$this->rest_base}/methods/(?P<id>\d+)/test-rate", [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'test_method_rate' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Rates ─────────────────────────────────────────────────────────────

        register_rest_route( $this->namespace, "/{$this->rest_base}/methods/(?P<id>\d+)/rates", [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_method_rates' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_rate' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, "/{$this->rest_base}/rates/(?P<id>\d+)", [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_rate' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_rate' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, "/{$this->rest_base}/rates/(?P<id>\d+)/conditions", [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'replace_conditions' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );


        // ── Public checkout endpoint ──────────────────────────────────────────
        // WP.org R1 🅱4: public by design — guest checkout quotes shipping for
        // the CALLER'S OWN cart session. Pure rate calculation, no state
        // written, nothing customer-identifying returned.

        register_rest_route( $this->namespace, '/checkout/shipping-rates', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'get_checkout_rates' ],
                'permission_callback' => '__return_true', // Public by design: guest checkout — shipping quote for the caller's own cart session; writes nothing.
            ],
        ] );

        // ── Flat aliases (tester / external compat) ───────────────────────────

        register_rest_route( $this->namespace, '/shipping-methods', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_methods' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/shipping-zones', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_zones' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_zone' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
    }


    // ══════════════════════════════════════════════════════════════════════════
    // ZONE HANDLERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /shipping/zones
     */
    public function get_zones( WP_REST_Request $request ): WP_REST_Response {
        return new WP_REST_Response( $this->zone_repo->get_all(), 200 );
    }

    /**
     * POST /shipping/zones
     */
    public function create_zone( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $name = sanitize_text_field( $request->get_param( 'name' ) ?? '' );

        if ( $name === '' ) {
            return new WP_Error( 'tc_missing_name', __( 'Zone name is required.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        $id   = $this->zone_repo->create( [
            'name'     => $name,
            'position' => (int) ( $request->get_param( 'position' ) ?? 0 ),
            'status'   => $request->get_param( 'status' ) ?? 'active',
        ] );

        // KI-89: report a failed INSERT instead of returning 201 with a null body.
        // ShippingZone::create() (L86-98) returns lastInsertId(), which wpdb zeroes
        // on a failed insert (class-wpdb.php L2296-2300) ⇒ 0 means "not written".
        // Unchecked, get_by_id(0) is null and WP core's REST server flattens a null
        // result to a ZERO-BYTE body at the SAME status (L536-538) — a 201 that
        // created nothing.
        if ( ! $id ) {
            return new WP_Error( 'tc_create_failed', __( 'Failed to create shipping zone.', 'titancart-lite' ), [ 'status' => 500 ] );
        }

        $zone = $this->zone_repo->get_by_id( $id );
        return new WP_REST_Response( $zone, 201 );
    }

    /**
     * GET /shipping/zones/{id}
     */
    public function get_zone( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $zone = $this->zone_repo->get_by_id( (int) $request['id'] );

        if ( ! $zone ) {
            return new WP_Error( 'tc_zone_not_found', __( 'Shipping zone not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        return new WP_REST_Response( $zone, 200 );
    }

    /**
     * PUT /shipping/zones/{id}
     */
    public function update_zone( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = (int) $request['id'];
        $zone = $this->zone_repo->get_by_id( $id );

        if ( ! $zone ) {
            return new WP_Error( 'tc_zone_not_found', __( 'Shipping zone not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $data = array_filter( [
            'name'     => $request->get_param( 'name' )     !== null ? sanitize_text_field( $request->get_param( 'name' ) ) : null,
            'position' => $request->get_param( 'position' ) !== null ? (int) $request->get_param( 'position' ) : null,
            'status'   => $request->get_param( 'status' ),
        ], fn( $v ) => $v !== null );

        $this->zone_repo->update( $id, $data );
        return new WP_REST_Response( $this->zone_repo->get_by_id( $id ), 200 );
    }

    /**
     * DELETE /shipping/zones/{id}
     */
    public function delete_zone( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = (int) $request['id'];
        $zone = $this->zone_repo->get_by_id( $id );

        if ( ! $zone ) {
            return new WP_Error( 'tc_zone_not_found', __( 'Shipping zone not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $this->zone_repo->delete( $id );
        return new WP_REST_Response( [ 'deleted' => true, 'id' => $id ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // LOCATION HANDLERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /shipping/zones/{id}/locations
     */
    public function add_location( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $zone_id = (int) $request['id'];
        $zone    = $this->zone_repo->get_by_id( $zone_id );

        if ( ! $zone ) {
            return new WP_Error( 'tc_zone_not_found', __( 'Shipping zone not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $type = $request->get_param( 'type' ) ?? 'country';
        $code = sanitize_text_field( $request->get_param( 'code' ) ?? '' );

        if ( $code === '' ) {
            return new WP_Error( 'tc_missing_code', __( 'Location code is required.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        if ( ! in_array( $type, [ 'country', 'state', 'postcode' ], true ) ) {
            return new WP_Error( 'tc_invalid_type', __( 'Location type must be country, state, or postcode.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        $loc_id = $this->zone_repo->add_location( $zone_id, $type, $code );

        return new WP_REST_Response( [
            'id'      => $loc_id,
            'zone_id' => $zone_id,
            'type'    => $type,
            'code'    => strtoupper( $code ),
        ], 201 );
    }

    /**
     * DELETE /shipping/zones/{id}/locations/{loc_id}
     */
    public function remove_location( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $loc_id = (int) $request['loc_id'];
        $this->zone_repo->remove_location( $loc_id );
        return new WP_REST_Response( [ 'deleted' => true, 'id' => $loc_id ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // METHOD HANDLERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /shipping/methods
     */
    public function get_methods( WP_REST_Request $request ): WP_REST_Response {
        // KI-96 (S429): mask carrier credentials before they leave the API. This
        // route is the SPA's real exposure surface — openEditMethod() hydrates the
        // Configure tab from THIS response and never re-fetches the method.
        return new WP_REST_Response( $this->mask_method_secrets_all( $this->manager->get_all_methods() ), 200 );
    }

    /**
     * POST /shipping/methods
     */
    public function create_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $carrier = sanitize_text_field( $request->get_param( 'carrier' ) ?? 'flat' );
        $name    = sanitize_text_field( $request->get_param( 'name' )   ?? '' );

        // Auto-generate a name from the carrier type when the caller omits it.
        if ( $name === '' ) {
            $name = ucfirst( str_replace( '_', ' ', $carrier ) ) . ' Rate';
        }

        if ( $this->carrier_requires_pro( $carrier ) ) {
            // Lite (.org) build: the gate above is stripped and the carrier
            // classes are physically absent (Carriers/ excluded), so a realtime
            // type is plain invalid input — reject like any unknown carrier
            // string, with no upgrade path (R1 §🅰).
            if ( ! class_exists( \TitanCart\Modules\Shipping\Carriers\USPSCarrier::class ) ) {
                return new WP_Error( 'tc_unknown_carrier', __( 'Unknown carrier type.', 'titancart-lite' ), [ 'status' => 422 ] );
            }
        }

        $data = [
            'carrier'      => $carrier,
            'position'     => (int) ( $request->get_param( 'position' )    ?? 0 ),
            'status'       => $request->get_param( 'status' )               ?? 'active',
            'min_weight'   => $request->get_param( 'min_weight' ),
            'max_weight'   => $request->get_param( 'max_weight' ),
            'tax_class_id' => $request->get_param( 'tax_class_id' ),
            // KI-96 (S429): there is no stored row to restore from on create, so
            // an incoming mask normalises to '' instead of persisting the literal
            // bullet string as if it were a credential.
            'settings'     => $this->preserve_masked_secrets( $request->get_param( 'settings' ) ?? [], [] ),
        ];

        $translations = $this->extract_translations( $request, $name );

        $id = $this->manager->create_method( $data, $translations );

        // KI-89: report a failed INSERT instead of returning 201 with a null body.
        // ShippingManager::create_method() (L481-487) returns lastInsertId() ⇒ 0 on a
        // failed insert. ⚠️ The guard is placed BEFORE the LogService call on purpose:
        // unguarded, this route wrote an audit entry reading "Shipping method created:
        // <name> (#0)" for a method that was never created — the log affirmed a write
        // that did not happen.
        if ( ! $id ) {
            return new WP_Error( 'tc_create_failed', __( 'Failed to create shipping method.', 'titancart-lite' ), [ 'status' => 500 ] );
        }

        $method = $this->manager->get_method_by_id( $id );

        LogService::info( 'general', 'create', 'Shipping method created: ' . $name . ' (#' . $id . ')', [
            'object_type' => 'shipping_method',
            'object_id'   => $id,
            'carrier'     => $carrier,
        ] );

        // KI-96 (S429): the create echo runs the same serializer as the GETs.
        return new WP_REST_Response( $this->mask_method_secrets( $method ), 201 );
    }

    /**
     * GET /shipping/methods/{id}
     */
    public function get_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $method = $this->manager->get_method_by_id( (int) $request['id'] );

        if ( ! $method ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        // KI-96 (S429).
        return new WP_REST_Response( $this->mask_method_secrets( $method ), 200 );
    }

    /**
     * PUT /shipping/methods/{id}
     */
    public function update_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request['id'];
        $method = $this->manager->get_method_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $incoming_carrier = $request->get_param( 'carrier' ) !== null
            ? sanitize_text_field( (string) $request->get_param( 'carrier' ) )
            : '';
        if ( $this->carrier_requires_pro( (string) ( $method['carrier'] ?? '' ) )
            || $this->carrier_requires_pro( $incoming_carrier ) ) {
            // Lite (.org) build: the gate above is stripped; realtime types are
            // plain invalid input there (Carriers/ absent) — no upgrade path.
            if ( ! class_exists( \TitanCart\Modules\Shipping\Carriers\USPSCarrier::class ) ) {
                return new WP_Error( 'tc_unknown_carrier', __( 'Unknown carrier type.', 'titancart-lite' ), [ 'status' => 422 ] );
            }
        }

        $data = array_filter( [
            'carrier'      => $request->get_param( 'carrier' )      !== null ? sanitize_text_field( $request->get_param( 'carrier' ) ) : null,
            'position'     => $request->get_param( 'position' )     !== null ? (int) $request->get_param( 'position' )                 : null,
            'status'       => $request->get_param( 'status' ),
            'min_weight'   => $request->get_param( 'min_weight' ),
            'max_weight'   => $request->get_param( 'max_weight' ),
            'tax_class_id' => $request->get_param( 'tax_class_id' ),
            // ⚠️ KI-96 (S429) — THE OTHER HALF OF THE FIX, and it must not be
            // separated from the masking above. ShippingManager::update_method()
            // L589-591 replaces the settings JSON WHOLESALE with no merge, so a
            // normal SPA save round-trips the mask straight over a live carrier
            // credential. $method was already fetched at the top of this handler,
            // so restoring costs no extra query.
            'settings'     => $request->get_param( 'settings' ) !== null
                ? $this->preserve_masked_secrets(
                    $request->get_param( 'settings' ),
                    is_array( $method['settings'] ?? null ) ? $method['settings'] : []
                )
                : null,
        ], fn( $v ) => $v !== null );

        $translations = [];
        $raw_trans    = $request->get_param( 'translations' );
        if ( is_array( $raw_trans ) ) {
            $translations = $raw_trans;
        }

        $this->manager->update_method( $id, $data, $translations );

        LogService::info( 'general', 'update', 'Shipping method updated: #' . $id, [
            'object_type' => 'shipping_method',
            'object_id'   => $id,
        ] );

        // KI-96 (S429): without this, every save echoes the credential back.
        return new WP_REST_Response( $this->mask_method_secrets( $this->manager->get_method_by_id( $id ) ), 200 );
    }

    /**
     * DELETE /shipping/methods/{id}  — real, cascading delete.
     *
     * KI-97 (S424). This route previously called disable_method() and returned
     * {disabled:true}, so the SPA's "Del" button could never remove a row and,
     * on an already-disabled method, had no observable effect at all.
     *
     * Deleting is safe for history: tc_orders keeps its own shipping_info name
     * snapshot, and both OrderManager and ShipmentManager LEFT JOIN the method
     * tables. The usage counts below do NOT gate the delete — they are recorded
     * so the audit log preserves the one fact the delete destroys: how much
     * there was.
     */
    public function delete_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request['id'];
        $method = $this->manager->get_method_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $usage = $this->manager->count_method_usage( $id );

        if ( ! $this->manager->delete_method( $id ) ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        LogService::info(
            'general',
            'delete',
            sprintf(
                'Shipping method deleted: #%d (rates: %d, conditions: %d, referenced by %d order(s), %d shipment(s))',
                $id,
                $usage['rates'],
                $usage['conditions'],
                $usage['orders'],
                $usage['shipments']
            ),
            [
                'object_type' => 'shipping_method',
                'object_id'   => $id,
            ]
        );

        return new WP_REST_Response( [ 'deleted' => true, 'id' => $id, 'usage' => $usage ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // KI-96 — CARRIER CREDENTIAL MASKING (S429)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Placeholder returned in place of a stored credential.
     *
     * S430: now sourced from the ONE shared definition in
     * \TitanCart\Core\Secrets — Payments references the same constant, so the
     * two modules cannot drift. (Drift was a security hazard, not tidiness:
     * a merge that stopped recognising the sentinel would write literal
     * bullets over a live credential.) Payments established this contract
     * first; Shipping adopted it at S429.
     */
    private const SECRET_MASK = \TitanCart\Core\Secrets::MASK;

    /**
     * Settings keys treated as write-only credentials.
     *
     * Payments derives its list from each gateway's field schema (`type` =>
     * `password`); shipping methods have no such schema — the SPA hardcodes the
     * field list — so this is explicit. `api_client_id` is deliberately ABSENT:
     * it is an identifier, not a credential (KI-96 build-shape step 4).
     */
    private const SECRET_SETTING_KEYS = [ 'api_client_secret' ];

    /**
     * Replace stored credentials with the mask before a method leaves the API.
     *
     * ⚠️⚠️ Masking happens HERE, at the REST boundary, and NOWHERE BELOW IT.
     * ShippingManager::get_realtime_rates() L1005 calls get_method_by_id() and
     * reads settings['api_client_secret'] at L1032/L1038 to sign live UPS/USPS/
     * FedEx calls. Masking inside the manager would break every realtime quote at
     * RUNTIME — a far worse failure than the one this ticket fixes, and one no
     * save-path test would catch.
     *
     * ★ MASKED, NOT STRIPPED — a deliberate divergence from Payments, which
     * unsets `settings` wholesale on its list route. The shipping SPA's
     * openEditMethod() (titancart-admin.js L25500) hydrates its ENTIRE Configure
     * tab from the LIST response's settings blob — delivery_time, service_codes,
     * box_*, zone_times, require_address — and never re-fetches the method
     * (L25543 loads only rates). Unsetting settings here would blank the edit
     * form for every method on the page.
     *
     * @param  mixed $method  Method row; anything else passes through untouched.
     * @return mixed
     */
    private function mask_method_secrets( mixed $method ): mixed {
        if ( ! is_array( $method ) || ! isset( $method['settings'] ) || ! is_array( $method['settings'] ) ) {
            return $method;
        }

        foreach ( self::SECRET_SETTING_KEYS as $key ) {
            // Only mask something that is actually there: masking an empty value
            // would tell the SPA a credential exists when none does, and the
            // write path would then "preserve" that nothing forever.
            if ( isset( $method['settings'][ $key ] ) && $method['settings'][ $key ] !== '' ) {
                $method['settings'][ $key ] = self::SECRET_MASK;
            }
        }

        return $method;
    }

    /**
     * List form of mask_method_secrets().
     *
     * @param  array $methods
     * @return array
     */
    private function mask_method_secrets_all( array $methods ): array {
        return array_map( [ $this, 'mask_method_secrets' ], $methods );
    }

    /**
     * Restore a credential the caller could not have known, before a write.
     *
     * ⚠️⚠️ THE HAZARD THIS EXISTS FOR — KI-96 build-shape step 2, and the reason
     * the read and write halves had to ship in ONE pass.
     * ShippingManager::update_method() L589-591 json_encodes the incoming settings
     * array and writes it WHOLESALE — there is no merge anywhere below this point.
     * Once the read path is masked, a perfectly normal Save round-trips the mask
     * (titancart-admin.js L25513 hydrates the field, L25558 sends it straight
     * back), so WITHOUT this method the first save after masking would overwrite a
     * live carrier credential with eight bullet characters — silently, with a 200.
     *
     * ★ A useful property of doing it this way: because the mask is treated as
     * "unchanged", the EXISTING deployed bundle stays correct with no JS change at
     * all. A stale cached bundle cannot corrupt a credential either.
     *
     * "Unchanged" means the mask, an empty string, or null — all three mean the
     * merchant did not type a new secret. Any other non-empty value is a genuine
     * new credential and overwrites.
     *
     * ⚠️ Deliberately scoped to SECRET_SETTING_KEYS ONLY. This is a security pass,
     * not a redefinition of how settings save: every other key keeps the existing
     * wholesale-replace behaviour, so nothing outside the credential changes.
     *
     * @param  mixed $incoming  Settings as supplied by the request.
     * @param  array $stored    Settings already persisted on the row.
     * @return mixed
     */
    private function preserve_masked_secrets( mixed $incoming, array $stored ): mixed {
        if ( ! is_array( $incoming ) ) {
            return $incoming;
        }

        foreach ( self::SECRET_SETTING_KEYS as $key ) {
            if ( ! array_key_exists( $key, $incoming ) ) {
                continue;
            }

            $value = $incoming[ $key ];

            if ( $value === self::SECRET_MASK || $value === '' || $value === null ) {
                $incoming[ $key ] = (string) ( $stored[ $key ] ?? '' );
            }
        }

        return $incoming;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // RATE HANDLERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /shipping/methods/{id}/rates
     */
    public function get_method_rates( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $method_id = (int) $request['id'];

        if ( ! $this->manager->get_method_by_id( $method_id ) ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        return new WP_REST_Response( $this->rate_repo->get_for_method( $method_id ), 200 );
    }

    /**
     * POST /shipping/methods/{id}/rates
     */
    public function create_rate( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $method_id = (int) $request['id'];

        if ( ! $this->manager->get_method_by_id( $method_id ) ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $zone_id = (int) ( $request->get_param( 'zone_id' ) ?? 0 );
        if ( $zone_id <= 0 ) {
            return new WP_Error( 'tc_missing_zone', __( 'zone_id is required.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        if ( ! $this->zone_repo->get_by_id( $zone_id ) ) {
            return new WP_Error( 'tc_zone_not_found', __( 'Shipping zone not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $rate_type = $request->get_param( 'rate_type' ) ?? 'flat';
        if ( ! in_array( $rate_type, [ 'flat', 'by_weight', 'by_price', 'by_items', 'free' ], true ) ) {
            return new WP_Error( 'tc_invalid_rate_type', __( 'Invalid rate_type.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        $conditions = $this->sanitize_conditions( $request->get_param( 'conditions' ) ?? [] );

        // For flat type, create a single condition if a 'price' param was passed directly.
        if ( $rate_type === 'flat' && empty( $conditions ) ) {
            $price      = $request->get_param( 'price' );
            $conditions = [ [ 'from_value' => '0.0000', 'to_value' => null, 'price' => (string) ( $price ?? '0.0000' ) ] ];
        }

        $rate_id = $this->rate_repo->create(
            [
                'method_id' => $method_id,
                'zone_id'   => $zone_id,
                'rate_type' => $rate_type,
                'position'  => (int) ( $request->get_param( 'position' ) ?? 0 ),
            ],
            $conditions
        );

        // KI-89: report a failed INSERT instead of returning 201 with a null body.
        // ShippingRate::create() (L89-113) returns lastInsertId() ⇒ 0 on a failed
        // insert, and get_by_id(0) is null ⇒ WP core flattens it to a zero-byte 201.
        if ( ! $rate_id ) {
            return new WP_Error( 'tc_create_failed', __( 'Failed to create shipping rate.', 'titancart-lite' ), [ 'status' => 500 ] );
        }

        return new WP_REST_Response( $this->rate_repo->get_by_id( $rate_id ), 201 );
    }

    /**
     * PUT /shipping/rates/{id}
     */
    public function update_rate( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = (int) $request['id'];
        $rate = $this->rate_repo->get_by_id( $id );

        if ( ! $rate ) {
            return new WP_Error( 'tc_rate_not_found', __( 'Shipping rate not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $data = array_filter( [
            'zone_id'   => $request->get_param( 'zone_id' )   !== null ? (int) $request->get_param( 'zone_id' )   : null,
            'rate_type' => $request->get_param( 'rate_type' ),
            'position'  => $request->get_param( 'position' )  !== null ? (int) $request->get_param( 'position' )  : null,
        ], fn( $v ) => $v !== null );

        $this->rate_repo->update( $id, $data );
        return new WP_REST_Response( $this->rate_repo->get_by_id( $id ), 200 );
    }

    /**
     * DELETE /shipping/rates/{id}
     */
    public function delete_rate( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = (int) $request['id'];
        $rate = $this->rate_repo->get_by_id( $id );

        if ( ! $rate ) {
            return new WP_Error( 'tc_rate_not_found', __( 'Shipping rate not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $this->rate_repo->delete( $id );
        return new WP_REST_Response( [ 'deleted' => true, 'id' => $id ], 200 );
    }

    /**
     * PUT /shipping/rates/{id}/conditions  — replace the full condition set.
     */
    public function replace_conditions( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id   = (int) $request['id'];
        $rate = $this->rate_repo->get_by_id( $id );

        if ( ! $rate ) {
            return new WP_Error( 'tc_rate_not_found', __( 'Shipping rate not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $conditions = $this->sanitize_conditions( $request->get_param( 'conditions' ) ?? [] );
        $this->rate_repo->replace_conditions( $id, $conditions );

        return new WP_REST_Response( $this->rate_repo->get_by_id( $id ), 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PUBLIC CHECKOUT HANDLER
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /checkout/shipping-rates
     *
     * Accepts a cart hash and shipping address; returns the available
     * shipping methods with calculated costs.
     *
     * Request body:
     *   {
     *     "cart":    { "subtotal": 99.99, "items": [...] },
     *     "address": { "country": "US", "state": "CA", "postcode": "90210" }
     *   }
     */
    public function get_checkout_rates( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $cart    = $request->get_param( 'cart' );
        $address = $request->get_param( 'address' );

        if ( ! is_array( $cart ) || ! is_array( $address ) ) {
            return new WP_Error(
                'tc_missing_params',
                __( 'Both cart and address are required.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        if ( empty( $address['country'] ) ) {
            return new WP_Error(
                'tc_missing_country',
                __( 'Shipping address must include a country code.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        $rates = $this->manager->get_available_rates( $cart, $address );

        return new WP_REST_Response( [
            'rates'   => $rates,
            'count'   => count( $rates ),
        ], 200 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN RATE DRY RUN  (KI-95)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /shipping/methods/{id}/test-rate
     *
     * Server-side dry run for the admin "Test Rate Calculation" tab.
     *
     * Runs the REAL engine — ShippingManager::get_available_rates() — against a
     * synthetic cart and a merchant-supplied test address, then reports only the
     * rows belonging to this method. It is deliberately the SAME call the public
     * checkout endpoint makes (get_checkout_rates() above), so what this tab
     * displays cannot drift from what checkout validates and charges.
     *
     * Before KI-95 the tab re-implemented zone selection and band matching in
     * JavaScript, picked an arbitrary zone rather than resolving one from an
     * address, and had no realtime path at all — so it could report a number the
     * engine would never produce, or (far more often) a silent row of dashes.
     *
     * Request body:
     *   {
     *     "subtotal": 100.00,
     *     "weight":   5,          // POUNDS — the canonical internal unit (KI-93)
     *     "items":    3,
     *     "address":  { "country": "US", "state": "PA", "postcode": "18465" }
     *   }
     *
     * Response (200) ALWAYS carries an explicit verdict — `matched` plus either
     * rates or a plain-English `reason`. A blank panel is not a reachable state:
     * that was the defect.
     *
     * ⚠️ A dry run against a realtime carrier makes a LIVE carrier API call and
     * spends the merchant's real quota, so realtime is Pro-gated here exactly as
     * the rate-PRODUCTION seam is (ShippingManager::get_realtime_rates).
     */
    public function test_method_rate( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request['id'];
        $method = $this->manager->get_method_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_method_not_found', __( 'Shipping method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $carrier    = strtolower( (string) ( $method['carrier'] ?? '' ) );
        $is_realtime = $this->carrier_requires_pro( $carrier );

        if ( $is_realtime ) {
            // Lite (.org) build: the gate above is stripped; a realtime method
            // cannot be created there and its carrier classes are absent, so a
            // leftover row is plain invalid input — no upgrade path (R1 §🅰).
            if ( ! class_exists( \TitanCart\Modules\Shipping\Carriers\USPSCarrier::class ) ) {
                return new WP_Error( 'tc_unknown_carrier', __( 'Unknown carrier type.', 'titancart-lite' ), [ 'status' => 422 ] );
            }
        }

        $raw_address = $request->get_param( 'address' );
        if ( ! is_array( $raw_address ) ) {
            $raw_address = [];
        }

        $address = [
            'country'  => strtoupper( sanitize_text_field( (string) ( $raw_address['country'] ?? '' ) ) ),
            'state'    => strtoupper( sanitize_text_field( (string) ( $raw_address['state'] ?? '' ) ) ),
            'postcode' => sanitize_text_field( (string) ( $raw_address['postcode'] ?? '' ) ),
            'city'     => sanitize_text_field( (string) ( $raw_address['city'] ?? '' ) ),
        ];

        // The engine's own precondition — surface it as a field error rather than
        // letting the zone matcher silently return nothing.
        if ( '' === $address['country'] ) {
            return new WP_Error(
                'tc_missing_country',
                __( 'A test address country is required.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        $subtotal    = max( 0.0, (float) $request->get_param( 'subtotal' ) );
        $weight      = max( 0.0, (float) $request->get_param( 'weight' ) );
        $items_input = (int) $request->get_param( 'items' );
        $lines       = max( 1, min( self::TEST_MAX_ITEM_LINES, $items_input ) );

        $cart = $this->synthesize_test_cart( $subtotal, $weight, $lines, $is_realtime );

        // Known by construction — synthesize_test_cart() is built so that
        // build_cart_totals() reproduces exactly these figures. Computed here
        // rather than re-derived so the diagnostics below and the engine agree.
        $cart_totals = [
            'subtotal'     => $subtotal,
            'item_count'   => $is_realtime ? 1 : $lines,
            'total_weight' => $weight,
        ];

        $rates = $this->manager->get_available_rates( $cart, $address );

        $mine = [];
        foreach ( $rates as $row ) {
            if ( (int) ( $row['method_id'] ?? 0 ) !== $id ) {
                continue;
            }
            $zone_id = (int) ( $row['zone_id'] ?? 0 );

            // ⚠️ `cost` is passed through UNROUNDED. The whole point of this route
            // is that the tab reports the engine's own number; rounding here would
            // reintroduce a (tiny) divergence between display and charging.
            $row['zone_name']     = $this->zone_name_for( $zone_id );
            $row['delivery_time'] = $this->delivery_time_for( $method, $zone_id );

            $mine[] = $row;
        }

        return new WP_REST_Response( [
            'matched'      => ! empty( $mine ),
            'rates'        => $mine,
            'count'        => count( $mine ),
            'other_count'  => count( $rates ) - count( $mine ),
            'reason'       => empty( $mine ) ? $this->explain_no_rate( $method, $address, $cart_totals ) : '',
            'cart_totals'  => $cart_totals,
            'realtime'     => $is_realtime,
            'lines_capped' => $items_input > self::TEST_MAX_ITEM_LINES,
        ], 200 );
    }

    /**
     * Build a synthetic cart whose totals land EXACTLY on the merchant's inputs.
     *
     * ★ The subtle part. The tab's inputs are TOTALS, but build_cart_totals()
     * (ShippingManager L680-695) sums `weight × quantity` per line. Deriving a
     * per-item weight by dividing total_weight by the item count reintroduces
     * float drift precisely at band boundaries — the one place it changes the
     * answer, since a 5 lb / 3 item cart becomes 4.999… lb and falls out of a
     * 0–5 band. So ONE line carries ALL the weight and a second line carries the
     * remaining quantity at zero weight: both totals are exact, with no division
     * anywhere.
     *
     * ⚠️⚠️ REALTIME NEEDS A DIFFERENT SHAPE, and this is not a style choice.
     * get_realtime_rates() L963 applies a per-LINE floor when building the carrier
     * package — a zero-weight line contributes 16 oz, not 0 (the deliberate KI-93
     * fallback so a merchant who never entered product weights keeps the historic
     * 1 lb/item behaviour rather than being quoted for a 1 oz parcel). Correct for
     * a real cart; wrong here, because the filler line is an artefact of this
     * synthesis and would silently inflate a 5 lb dry run into a 6 lb quote. So a
     * realtime dry run uses a SINGLE line carrying the whole weight.
     *
     * The cost of that: `item_count` is then 1 rather than n. Verified harmless —
     * no carrier reads `items_count` (it appears only in the four carriers'
     * docblocks, never in a body), and no `by_items` band participates in a
     * realtime quote. It is only observable if a merchant attaches by_items rate
     * rows to a realtime method, which is a configuration the engine treats as
     * two independent rate sources anyway.
     *
     * @param  float $subtotal   Cart subtotal.
     * @param  float $weight     TOTAL cart weight in POUNDS (KI-93 canonical).
     * @param  int   $lines      Total item count (already clamped by the caller).
     * @param  bool  $realtime   Whether the method under test is a live carrier.
     * @return array  {items, subtotal}
     */
    private function synthesize_test_cart( float $subtotal, float $weight, int $lines, bool $realtime ): array {
        $line = static function ( int $qty, float $w, float $price ): array {
            return [
                'product_id'        => 0,
                'quantity'          => $qty,
                'weight'            => $w,
                'price'             => $price,
                'requires_shipping' => 1,
            ];
        };

        // One line carrying the entire weight.
        $items = [ $line( 1, $weight, $subtotal ) ];

        // Non-realtime only: make up the remaining item count at zero weight, so
        // by_items bands see the real count while total_weight stays exact.
        if ( ! $realtime && $lines > 1 ) {
            $items[] = $line( $lines - 1, 0.0, 0.0 );
        }

        return [
            'items'    => $items,
            'subtotal' => $subtotal,
        ];
    }

    /**
     * Explain, in the merchant's terms, why the engine offered nothing.
     *
     * ★ This is the heart of KI-95. The old tab rendered an all-dashes panel for
     * every failure — and because the *initial* state was the identical all-dashes
     * object, "the button is dead" and "the calculation returned nothing" were the
     * same observable. Every branch below therefore returns a specific, actionable
     * sentence; there is no generic fallthrough that means "something went wrong".
     *
     * Checks run in the same order the engine applies them, so the reason names
     * the FIRST gate that actually excluded the method rather than a later one.
     *
     * @param  array $method       Method row (settings decoded, rates attached).
     * @param  array $address      Sanitised test address.
     * @param  array $cart_totals  {subtotal, item_count, total_weight}
     * @return string
     */
    private function explain_no_rate( array $method, array $address, array $cart_totals ): string {
        $carrier = strtolower( (string) ( $method['carrier'] ?? '' ) );
        $weight  = (float) $cart_totals['total_weight'];

        if ( 'active' !== ( $method['status'] ?? '' ) ) {
            return __( 'This shipping method is disabled, so the engine never offers it. Enable it on the General tab and test again.', 'titancart-lite' );
        }

        $min = isset( $method['min_weight'] ) && null !== $method['min_weight'] ? (float) $method['min_weight'] : null;
        $max = isset( $method['max_weight'] ) && null !== $method['max_weight'] ? (float) $method['max_weight'] : null;

        if ( null !== $min && $weight < $min ) {
            return sprintf(
                /* translators: 1: test weight, 2: configured minimum weight */
                __( 'The test weight (%1$s lb) is below this method\'s minimum of %2$s lb, so the engine excludes it. Adjust the weight window on the Additional Settings tab, or test with a heavier cart.', 'titancart-lite' ),
                rtrim( rtrim( number_format( $weight, 4, '.', '' ), '0' ), '.' ),
                rtrim( rtrim( number_format( $min, 4, '.', '' ), '0' ), '.' )
            );
        }

        if ( null !== $max && $weight > $max ) {
            return sprintf(
                /* translators: 1: test weight, 2: configured maximum weight */
                __( 'The test weight (%1$s lb) is above this method\'s maximum of %2$s lb, so the engine excludes it. Adjust the weight window on the Additional Settings tab, or test with a lighter cart.', 'titancart-lite' ),
                rtrim( rtrim( number_format( $weight, 4, '.', '' ), '0' ), '.' ),
                rtrim( rtrim( number_format( $max, 4, '.', '' ), '0' ), '.' )
            );
        }

        if ( in_array( $carrier, self::REALTIME_CARRIERS, true ) ) {
            return __( 'The carrier returned no rates for these inputs. Check the API credentials and test-mode toggle on the Configure tab, confirm the store Company address is filled in (it is used as the ship-from origin), and make sure the destination is one this carrier serves.', 'titancart-lite' );
        }

        if ( 'free' === $carrier ) {
            return __( 'Free shipping did not surface for these inputs. Free methods ignore rate areas, so this normally means the method is disabled or the cart weight falls outside its weight window.', 'titancart-lite' );
        }

        $rates = is_array( $method['rates'] ?? null ) ? $method['rates'] : [];

        if ( empty( $rates ) ) {
            return __( 'No rates are configured for this method. Add at least one rate on the "Shipping Time & Rates" tab — without a rate row the engine has nothing to offer.', 'titancart-lite' );
        }

        $zone_ids = array_map( 'intval', $this->zone_repo->get_matching_zone_ids( $address ) );

        if ( empty( $zone_ids ) ) {
            return __( 'No rate area matches this test address. Add the destination to a rate area under Shipping → Rate Areas, or create a worldwide area (one with no locations) as a catch-all.', 'titancart-lite' );
        }

        $rate_zone_ids = array_map( 'intval', array_column( $rates, 'zone_id' ) );

        if ( ! array_intersect( $rate_zone_ids, $zone_ids ) ) {
            return __( 'A rate area matches this address, but this method has no rates in it. Add a rate for that area on the "Shipping Time & Rates" tab.', 'titancart-lite' );
        }

        return __( 'A rate area matched, but no rate band covers these test values. Check the from/to ranges on the "Shipping Time & Rates" tab — a band must cover the subtotal, weight or item count you entered for it to apply.', 'titancart-lite' );
    }

    /**
     * Resolve a rate area's display name. Returns '' for zone_id 0, which is what
     * free and realtime rows carry — they are not zone-gated.
     *
     * @param  int $zone_id
     * @return string
     */
    private function zone_name_for( int $zone_id ): string {
        if ( $zone_id <= 0 ) {
            return '';
        }
        $zone = $this->zone_repo->get_by_id( $zone_id );
        return (string) ( $zone['name'] ?? '' );
    }

    /**
     * Resolve the delivery-time string the storefront would show for this method
     * in this zone: the per-zone override if one is set, else the method default.
     *
     * Both live in the method's `settings` JSON (`zone_times` map + `delivery_time`),
     * written by the SPA's save-payload builder. Numeric JSON object keys decode to
     * int array keys in PHP, so both key forms are tried rather than assumed.
     *
     * @param  array $method
     * @param  int   $zone_id
     * @return string
     */
    private function delivery_time_for( array $method, int $zone_id ): string {
        $settings = is_array( $method['settings'] ?? null ) ? $method['settings'] : [];
        $per_zone = is_array( $settings['zone_times'] ?? null ) ? $settings['zone_times'] : [];

        $time = '';
        if ( $zone_id > 0 ) {
            $time = (string) ( $per_zone[ $zone_id ] ?? $per_zone[ (string) $zone_id ] ?? '' );
        }

        if ( '' === $time ) {
            $time = (string) ( $settings['delivery_time'] ?? '' );
        }

        return $time;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PERMISSIONS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Require manage_woocommerce or manage_options for all admin routes.
     *
     * @return bool|\WP_Error
     */
    public function admin_permission(): bool|WP_Error {
        if ( current_user_can( 'manage_options' ) ) {
            return true;
        }
        return new WP_Error(
            'tc_rest_forbidden',
            __( 'You do not have permission to access this resource.', 'titancart-lite' ),
            [ 'status' => 403 ]
        );
    }

    /**
     * Whether a shipping method carrier is a Pro-only realtime carrier
     * (live-quoted USPS / UPS / FedEx rates). Manual / table / free carriers
     * return false = always Lite. Drives the create/update write gate.
     *
     * @param  string $carrier
     * @return bool
     */
    private function carrier_requires_pro( string $carrier ): bool {
        return $carrier !== '' && in_array( strtolower( $carrier ), self::REALTIME_CARRIERS, true );
    }

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

    /**
     * Build the translations array from request parameters.
     *
     * Supports two input shapes:
     *   1. A `translations` param: { "en": { "name": "...", "description": "..." } }
     *   2. A flat `name` / `description` param → stored as 'en'.
     *
     * @param  WP_REST_Request $request
     * @param  string          $fallback_name  Used when no name key is found in translations.
     * @return array[]
     */
    private function extract_translations( WP_REST_Request $request, string $fallback_name ): array {
        $raw = $request->get_param( 'translations' );

        if ( is_array( $raw ) ) {
            return $raw;
        }

        // Flat params → default English entry.
        return [
            'en' => [
                'name'        => $fallback_name,
                'description' => sanitize_textarea_field( $request->get_param( 'description' ) ?? '' ),
            ],
        ];
    }

    /**
     * Sanitize an array of condition objects coming from the REST request.
     *
     * @param  mixed $raw
     * @return array[]
     */
    private function sanitize_conditions( mixed $raw ): array {
        if ( ! is_array( $raw ) ) {
            return [];
        }

        $out = [];
        foreach ( $raw as $c ) {
            if ( ! is_array( $c ) ) {
                continue;
            }
            $price_type = isset( $c['price_type'] ) && $c['price_type'] === 'percent' ? 'percent' : 'fixed';
            $out[] = [
                'from_value' => (string) ( $c['from_value'] ?? '0.0000' ),
                'to_value'   => isset( $c['to_value'] ) && $c['to_value'] !== null && $c['to_value'] !== ''
                    ? (string) $c['to_value']
                    : null,
                'price'      => (string) ( $c['price'] ?? '0.0000' ),
                'price_type' => $price_type,
            ];
        }

        return $out;
    }
}
