<?php
/**
 * AccountManager — customer account operations.
 *
 * Coordinates CustomerRepository with WordPress user management to
 * provide a unified API for registration, profile edits, and the
 * customer address book.
 *
 * Every public method is callable from both the REST layer and the
 * storefront shortcode renderer — no WP_REST_Request dependency here.
 *
 * @package TitanCart\Modules\Accounts
 */

namespace TitanCart\Modules\Accounts;

use TitanCart\Modules\Orders\OrderManager;
use TitanCart\Services\SettingsService;

defined( 'ABSPATH' ) || exit;

class AccountManager {

    private CustomerRepository $repo;

    public function __construct() {
        $this->repo = new CustomerRepository();
    }

    // ══════════════════════════════════════════════════════════════════════════
    // CURRENT CUSTOMER RESOLUTION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Return the tc_customers row for the currently logged-in WP user.
     * Returns null for guests or when no row exists yet.
     *
     * @return array|null
     */
    public function get_current_customer(): ?array {
        $wp_user_id = get_current_user_id();
        if ( ! $wp_user_id ) {
            return null;
        }
        return $this->repo->find_by_wp_user( $wp_user_id );
    }

    /**
     * Return the tc_customers row, auto-creating it if this WP user has no
     * record yet (handles legacy users who registered before TitanCart).
     *
     * @return array|null  Null when not logged in.
     */
    public function get_or_create_current_customer(): ?array {
        $wp_user_id = get_current_user_id();
        if ( ! $wp_user_id ) {
            return null;
        }

        $customer = $this->repo->find_by_wp_user( $wp_user_id );
        if ( $customer ) {
            return $customer;
        }

        // Create a skeleton record from WP user data.
        $wp_user = get_userdata( $wp_user_id );
        if ( ! $wp_user ) {
            return null;
        }

        $id = $this->repo->create( [
            'wp_user_id' => $wp_user_id,
            'email'      => $wp_user->user_email,
            'first_name' => $wp_user->first_name,
            'last_name'  => $wp_user->last_name,
            'status'     => 'active',
            'created_at' => current_time( 'mysql' ),
            'updated_at' => current_time( 'mysql' ),
        ] );

        return $this->repo->find( $id );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REGISTRATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Register a new customer.
     *
     * Creates a WordPress user and a matching tc_customers record.
     *
     * @param  array $data {
     *   @type string $email       Required.
     *   @type string $password    Required.
     *   @type string $first_name  Optional.
     *   @type string $last_name   Optional.
     *   @type string $phone       Optional.
     * }
     * @return int|\WP_Error  Customer ID on success, WP_Error on failure.
     */
    public function register( array $data ) {
        $email     = sanitize_email( $data['email']     ?? '' );
        $password  = $data['password'] ?? '';
        $fname     = sanitize_text_field( $data['first_name'] ?? '' );
        $lname     = sanitize_text_field( $data['last_name']  ?? '' );
        $phone     = sanitize_text_field( $data['phone']      ?? '' );
        $company   = sanitize_text_field( $data['company']    ?? '' );
        $group_id  = absint( $data['usergroup_id'] ?? 0 );

        // Custom (non-system) contact fields configured via Customer Fields admin
        // page. Two acceptable input shapes:
        //   1. $data['custom_fields'] = [ field_id => value, ... ]      (pre-extracted)
        //   2. $data['tc_cf_5'] = '...', $data['tc_cf_7'] = '...'        (raw $_POST shape)
        //
        // Storefront templates pre-extract; REST clients can pass either shape.
        // Validation runs AFTER email/password checks (familiar errors first)
        // but BEFORE wp_create_user — never let an invalid required field
        // create a half-registered customer.
        $fields_manager = new CustomerFieldsManager();
        if ( is_array( $data['custom_fields'] ?? null ) ) {
            $custom_fields = $data['custom_fields'];
        } else {
            $custom_fields = $fields_manager->extract_submitted( $data );
        }

        if ( ! is_email( $email ) ) {
            return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'titancart-lite' ) );
        }

        if ( email_exists( $email ) ) {
            return new \WP_Error( 'email_exists', __( 'An account with this email already exists.', 'titancart-lite' ) );
        }

        $min_pw_len = SettingsService::int( 'security', 'min_password_length', 5 );
        if ( strlen( $password ) < $min_pw_len ) {
            return new \WP_Error( 'weak_password', sprintf(
                /* translators: %d: minimum password length in characters. */
                __( 'Password must be at least %d characters.', 'titancart-lite' ),
                $min_pw_len
            ) );
        }

        // Mixed letters+numbers requirement.
        if ( SettingsService::bool( 'security', 'password_require_mixed', false ) ) {
            if ( ! preg_match( '/[a-zA-Z]/', $password ) || ! preg_match( '/[0-9]/', $password ) ) {
                return new \WP_Error( 'weak_password', __( 'Password must contain both letters and numbers.', 'titancart-lite' ) );
            }
        }

        $custom_errors = $fields_manager->validate_required( 'contact', $custom_fields );

        if ( ! empty( $custom_errors ) ) {
            return new \WP_Error( 'custom_field_required', reset( $custom_errors ), [ 'errors' => $custom_errors ] );
        }

        // Create WP user.
        $username   = $this->generate_username( $email, $fname, $lname );
        $wp_user_id = wp_create_user( $username, $password, $email );

        if ( is_wp_error( $wp_user_id ) ) {
            return $wp_user_id;
        }

        // Update display name.
        wp_update_user( [
            'ID'           => $wp_user_id,
            'first_name'   => $fname,
            'last_name'    => $lname,
            'display_name' => trim( "{$fname} {$lname}" ) ?: $username,
        ] );

        // If admin activation is required, start as disabled.
        $needs_activation = SettingsService::bool( 'general', 'customers_admin_activate', false );
        $initial_status   = $needs_activation ? 'disabled' : 'active';

        // ── Referral attribution (S277, #5 / KI-13) ──
        // Core owns identity/attribution; the rewards extension does the payout.
        // Capture is UNCONDITIONAL (not gated on the rewards extension being active)
        // so that if a store enables Reward Points after some referred customers have
        // already signed up, their first order still pays out — the link is never
        // silently lost. Two things happen here:
        //   1. Resolve any referral code the new customer arrived with → the
        //      referrer's customer id (must be an existing, active, non-deleted,
        //      DIFFERENT customer). Stored as referred_by. Write-once: it is only
        //      ever set here, at registration.
        //   2. Mint this customer's OWN unique shareable referral_code.
        $referred_by = null;
        $incoming_code = sanitize_text_field( $data['referral_code'] ?? '' );
        if ( $incoming_code !== '' ) {
            $resolved = $this->resolve_referral_code( $incoming_code );
            if ( $resolved > 0 ) {
                $referred_by = $resolved;
            }
        }
        $own_referral_code = $this->generate_referral_code();

        // The `user_register` action fired inside wp_create_user() above will
        // have already created a skeleton tc_customers row via
        // AccountsModule::on_user_register (with empty first_name/last_name
        // because get_userdata() returns the user before wp_update_user runs).
        // If we naively INSERT again here, the UNIQUE(email) constraint silently
        // rejects the row, $wpdb->insert_id stays at 0, and the registration
        // data (first_name, last_name, phone) is never persisted. Custom field
        // values also get dropped because persist_section_values($customer_id=0)
        // early-returns. Detect the existing row and UPDATE it instead.
        $existing = $this->repo->find_by_wp_user( $wp_user_id );

        if ( $existing ) {
            $customer_id = (int) $existing['id'];
            $update = [
                'first_name'    => $fname,
                'last_name'     => $lname,
                'phone'         => $phone,
                'status'        => $initial_status,
                'referral_code' => $own_referral_code,
                'updated_at'    => current_time( 'mysql' ),
            ];
            if ( $referred_by !== null ) {
                $update['referred_by'] = $referred_by;
            }
            $this->repo->update( $customer_id, $update );
        } else {
            // Defensive fallback for cases where the hook did not fire (e.g.,
            // it was unhooked by an extension, or AccountManager::register was
            // called outside the normal storefront flow).
            $create = [
                'wp_user_id' => $wp_user_id,
                'email'      => $email,
                'first_name' => $fname,
                'last_name'  => $lname,
                'phone'      => $phone,
                'status'     => $initial_status,
                'referral_code' => $own_referral_code,
                'created_at' => current_time( 'mysql' ),
                'updated_at' => current_time( 'mysql' ),
            ];
            if ( $referred_by !== null ) {
                $create['referred_by'] = $referred_by;
            }
            $customer_id = $this->repo->create( $create );
        }

        // Persist custom (non-system) contact-section field values. Already
        // validated above; sanitize-and-write here. Done before the registered
        // hook fires so listeners (welcome emails, CRMs, etc.) see the full
        // record on the same tick.
        if ( ! empty( $custom_fields ) ) {
            $fields_manager->persist_section_values( (int) $customer_id, 'contact', $custom_fields );
        }

        /**
         * Fires after a new customer is registered.
         *
         * @param int $customer_id  tc_customers.id
         * @param int $wp_user_id   wp_users.ID
         * @param array $data       Raw registration data.
         * @param bool  $needs_activation  True if admin must activate.
         */
        do_action( 'titancart_customer_registered', $customer_id, $wp_user_id, $data, $needs_activation );

        // Store company as WP user meta (tc_customers table has no company column).
        if ( $company !== '' ) {
            update_user_meta( $wp_user_id, 'tc_company', $company );
        }

        // Assign to customer group if selected during registration.
        if ( $group_id > 0 ) {
            $db = titancart_db();
            $exists = $db->getField(
                "SELECT id FROM {$db->table('usergroups')} WHERE id = ?i AND status = 'active' LIMIT 1",
                $group_id
            );
            if ( $exists ) {
                $db->query(
                    "INSERT IGNORE INTO {$db->table('customer_usergroups')} (customer_id, group_id, joined_at) VALUES (?i, ?i, ?s)",
                    $customer_id, $group_id, titancart_now_utc() // KI-24: UTC, not MySQL-tz NOW()
                );
            }
        }

        // Stamp password age for password_validity_days enforcement.
        update_user_meta( $wp_user_id, 'tc_password_changed_at', time() );

        return $customer_id;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REFERRAL ATTRIBUTION (S277, #5 / KI-13)
    // ══════════════════════════════════════════════════════════════════════════
    //
    // Core identity/attribution for the referral reward event. The referral CODE
    // and the referred_by link are 1:1 customer attributes (canonical columns on
    // tc_customers, Migration 55); the payout/audit record lives in
    // tc_reward_point_log. The rewards extension consumes these but does not own
    // them — so attribution survives the extension being toggled off and on.

    /**
     * Generate a fresh, unique, human-friendly referral code.
     *
     * 8 chars from an unambiguous alphabet (no 0/O/1/I/L) → ~8.5e11 combinations.
     * Re-rolls on the astronomically-rare collision; the UNIQUE(referral_code)
     * index is the hard backstop. Falls back to extra entropy if 12 rolls somehow
     * all collide (effectively impossible).
     *
     * @return string The minted code (uppercase).
     */
    public function generate_referral_code(): string {
        $db      = titancart_db();
        $charset = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
        $max     = strlen( $charset ) - 1;
        $code    = '';
        for ( $attempt = 0; $attempt < 12; $attempt++ ) {
            $code = '';
            for ( $i = 0; $i < 8; $i++ ) {
                $code .= $charset[ random_int( 0, $max ) ];
            }
            $exists = (int) $db->getField(
                "SELECT COUNT(*) FROM {$db->table('customers')} WHERE referral_code = ?s",
                $code
            );
            if ( $exists === 0 ) {
                return $code;
            }
        }
        return $code . strtoupper( substr( bin2hex( random_bytes( 2 ) ), 0, 4 ) );
    }

    /**
     * Resolve a referral code to the referrer's customer id.
     *
     * Only an existing, active, non-deleted customer qualifies. Returns 0 when the
     * code is empty, unknown, or belongs to a disabled/deleted account — callers
     * treat 0 as "no valid referrer".
     *
     * @param  string $code  The code a referee arrived with (case-insensitive).
     * @return int           Referrer customer id, or 0 if none.
     */
    public function resolve_referral_code( string $code ): int {
        $code = strtoupper( trim( $code ) );
        if ( $code === '' ) {
            return 0;
        }
        $db = titancart_db();
        return (int) $db->getField(
            "SELECT id FROM {$db->table('customers')}
             WHERE referral_code = ?s AND status = 'active' AND deleted_at IS NULL
             LIMIT 1",
            $code
        );
    }

    /**
     * Return a customer's referral code, minting one on first access.
     *
     * Lazy backfill for customers created before Migration 55 (or via paths other
     * than register(), e.g. an admin-created WP user or a skeleton auto-create).
     * Never touches referred_by.
     *
     * @param  int $customer_id
     * @return string  The code, or '' for an invalid customer id.
     */
    public function get_or_create_referral_code( int $customer_id ): string {
        if ( $customer_id <= 0 ) {
            return '';
        }
        $db  = titancart_db();
        // #243: read a possibly-NULL column with getRow, not getField (getField
        // can't distinguish NULL/'' from "no row").
        $row = $db->getRow(
            "SELECT referral_code FROM {$db->table('customers')} WHERE id = ?i LIMIT 1",
            $customer_id
        );
        if ( $row && ! empty( $row['referral_code'] ) ) {
            return (string) $row['referral_code'];
        }
        $code = $this->generate_referral_code();
        $this->repo->update( $customer_id, [ 'referral_code' => $code ] );
        return $code;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PROFILE
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Get a customer's profile data.
     *
     * @param  int $customer_id
     * @return array|null
     */
    public function get_profile( int $customer_id ): ?array {
        return $this->repo->find( $customer_id );
    }

    /**
     * Update the profile fields for a customer.
     *
     * Also syncs first_name / last_name / email to the WP user record.
     *
     * @param  int   $customer_id
     * @param  array $data {
     *   @type string $first_name
     *   @type string $last_name
     *   @type string $email
     *   @type string $phone
     *   @type string $new_password   Optional — change password.
     *   @type string $current_password Required when changing password.
     * }
     * @return true|\WP_Error
     */
    public function update_profile( int $customer_id, array $data ) {
        $customer = $this->repo->find( $customer_id );
        if ( ! $customer ) {
            return new \WP_Error( 'not_found', __( 'Customer not found.', 'titancart-lite' ) );
        }

        // Custom (non-system) contact field values. Map of field_id => raw
        // value; validated up front so we never partially persist when a
        // required field is empty.
        //
        // Two acceptable input shapes:
        //   1. $data['custom_fields'] = [ field_id => value, ... ]      (pre-extracted)
        //   2. $data['tc_cf_5'] = '...', $data['tc_cf_7'] = '...'        (raw $_POST shape)
        //
        // Storefront templates and the AccountsModule profile handler pass
        // raw $_POST. REST callers can pre-extract.
        $fields_manager = new CustomerFieldsManager();
        if ( is_array( $data['custom_fields'] ?? null ) ) {
            $custom_fields = $data['custom_fields'];
        } else {
            $custom_fields = $fields_manager->extract_submitted( $data );
        }

        $custom_errors  = $fields_manager->validate_required( 'contact', $custom_fields );

        if ( ! empty( $custom_errors ) ) {
            return new \WP_Error( 'custom_field_required', reset( $custom_errors ), [ 'errors' => $custom_errors ] );
        }

        $update_tc  = [];
        $update_wp  = [];
        $wp_user_id = (int) $customer['wp_user_id'];

        if ( isset( $data['first_name'] ) ) {
            $update_tc['first_name'] = sanitize_text_field( $data['first_name'] );
            $update_wp['first_name'] = $update_tc['first_name'];
        }
        if ( isset( $data['last_name'] ) ) {
            $update_tc['last_name'] = sanitize_text_field( $data['last_name'] );
            $update_wp['last_name'] = $update_tc['last_name'];
        }
        if ( isset( $data['phone'] ) ) {
            $update_tc['phone'] = sanitize_text_field( $data['phone'] );
        }
        // Date of birth (S276, #5 / KI-13 birthday bonus). Stored as DATE or NULL.
        // Empty string clears it (writes NULL — '' is not a valid DATE). A
        // non-empty value must be a real, non-future calendar date.
        if ( isset( $data['date_of_birth'] ) ) {
            $dob = self::sanitize_dob( $data['date_of_birth'] );
            if ( $dob === false ) {
                return new \WP_Error( 'invalid_dob', __( 'Please enter a valid date of birth.', 'titancart-lite' ) );
            }
            $update_tc['date_of_birth'] = $dob; // string YYYY-MM-DD or null
        }
        if ( isset( $data['email'] ) ) {
            $new_email = sanitize_email( $data['email'] );
            if ( ! is_email( $new_email ) ) {
                return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'titancart-lite' ) );
            }
            // Check uniqueness — allow keeping the same email.
            if ( strtolower( $new_email ) !== strtolower( $customer['email'] ) ) {
                if ( email_exists( $new_email ) ) {
                    return new \WP_Error( 'email_exists', __( 'That email address is already in use.', 'titancart-lite' ) );
                }
            }
            $update_tc['email'] = $new_email;
            $update_wp['user_email'] = $new_email;
        }

        // Password change.
        if ( ! empty( $data['new_password'] ) ) {
            if ( empty( $data['current_password'] ) ) {
                return new \WP_Error( 'password_required', __( 'Enter your current password to set a new one.', 'titancart-lite' ) );
            }
            $wp_user = get_userdata( $wp_user_id );
            if ( ! $wp_user || ! wp_check_password( $data['current_password'], $wp_user->user_pass, $wp_user_id ) ) {
                return new \WP_Error( 'wrong_password', __( 'Current password is incorrect.', 'titancart-lite' ) );
            }
            $min_pw = SettingsService::int( 'security', 'min_password_length', 5 );
            if ( strlen( $data['new_password'] ) < $min_pw ) {
                return new \WP_Error( 'weak_password', sprintf(
                    /* translators: %d: minimum password length in characters. */
                    __( 'New password must be at least %d characters.', 'titancart-lite' ),
                    $min_pw
                ) );
            }
            if ( SettingsService::bool( 'security', 'password_require_mixed', false ) ) {
                if ( ! preg_match( '/[a-zA-Z]/', $data['new_password'] ) || ! preg_match( '/[0-9]/', $data['new_password'] ) ) {
                    return new \WP_Error( 'weak_password', __( 'Password must contain both letters and numbers.', 'titancart-lite' ) );
                }
            }
            $update_wp['user_pass'] = $data['new_password'];
            // Reset password age timer.
            update_user_meta( $wp_user_id, 'tc_password_changed_at', time() );
            // Clear force-change flag and expiry flag if either was set, so the
            // post-change navigation doesn't bounce them back to this page.
            delete_user_meta( $wp_user_id, 'tc_must_change_password' );
            delete_user_meta( $wp_user_id, 'tc_password_expired' );
        }

        if ( ! empty( $update_tc ) ) {
            $update_tc['updated_at'] = current_time( 'mysql' );
            $this->repo->update( $customer_id, $update_tc );
        }

        if ( $wp_user_id && ! empty( $update_wp ) ) {
            $update_wp['ID'] = $wp_user_id;
            wp_update_user( $update_wp );
        }

        // Custom (non-system) contact field values — sanitize-and-write.
        // Already validated above. Done after fixed-column writes so the
        // do_action below sees the full updated record on the same tick.
        if ( ! empty( $custom_fields ) ) {
            $fields_manager->persist_section_values( $customer_id, 'contact', $custom_fields );
        }

        do_action( 'titancart_customer_profile_updated', $customer_id, $data );

        return true;
    }

    /**
     * Validate/normalize a date-of-birth input. Shared by the storefront profile
     * editor (here) and the admin customer editor (CustomersController) so both
     * surfaces enforce the same rule (S276, #5 / KI-13).
     *
     * @param  mixed $raw  Raw input (string expected).
     * @return string|null|false  'YYYY-MM-DD' for a valid, non-future date;
     *                            null when empty (caller clears the column);
     *                            false when malformed/invalid (caller rejects).
     */
    public static function sanitize_dob( $raw ) {
        $dob = is_scalar( $raw ) ? trim( (string) $raw ) : '';
        if ( $dob === '' ) {
            return null;
        }
        if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $dob, $m ) ) {
            return false;
        }
        if ( ! checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) {
            return false;
        }
        // Reject future dates (lexicographic compare is correct for ISO dates).
        if ( $dob > current_time( 'Y-m-d' ) ) {
            return false;
        }
        return $dob;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADDRESS BOOK
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Get all addresses for a customer, optionally filtered by type.
     *
     * @param  int         $customer_id
     * @param  string|null $type  'billing'|'shipping'|null
     * @return array
     */
    public function get_addresses( int $customer_id, ?string $type = null ): array {
        return $this->repo->get_addresses( $customer_id, $type );
    }

    /**
     * Save (create or update) an address for a customer.
     *
     * @param  int      $customer_id
     * @param  array    $data
     * @param  int|null $address_id  Provide to update existing.
     * @return int|\WP_Error  Address ID on success.
     */
    public function save_address( int $customer_id, array $data, ?int $address_id = null ) {
        $errors = $this->validate_address( $data );
        if ( ! empty( $errors ) ) {
            return new \WP_Error( 'validation', reset( $errors ), [ 'errors' => $errors ] );
        }

        $type = in_array( $data['type'] ?? '', [ 'billing', 'shipping' ], true )
            ? $data['type']
            : 'shipping';

        $row = [
            'customer_id' => $customer_id,
            'type'        => $type,
            'first_name'  => sanitize_text_field( $data['first_name']  ?? '' ),
            'last_name'   => sanitize_text_field( $data['last_name']   ?? '' ),
            'company'     => sanitize_text_field( $data['company']     ?? '' ),
            'address_1'   => sanitize_text_field( $data['address_1']   ?? '' ),
            'address_2'   => sanitize_text_field( $data['address_2']   ?? '' ),
            'city'        => sanitize_text_field( $data['city']        ?? '' ),
            'state'       => sanitize_text_field( $data['state']       ?? '' ),
            'postcode'    => sanitize_text_field( $data['postcode']    ?? '' ),
            'country'     => strtoupper( substr( sanitize_text_field( $data['country'] ?? '' ), 0, 2 ) ),
            'phone'       => sanitize_text_field( $data['phone']       ?? '' ),
        ];

        if ( $address_id ) {
            $existing = $this->repo->get_address( $address_id, $customer_id );
            if ( ! $existing ) {
                return new \WP_Error( 'not_found', __( 'Address not found.', 'titancart-lite' ) );
            }
            $this->repo->update_address( $address_id, $customer_id, $row );
            return $address_id;
        }

        $new_id = $this->repo->create_address( $row );

        // Auto-default if first address of this type.
        $all = $this->repo->get_addresses( $customer_id, $type );
        if ( count( $all ) === 1 ) {
            $this->repo->set_default_address( $customer_id, $new_id );
        }

        return $new_id;
    }

    /**
     * Delete a saved address.
     *
     * @param  int $customer_id
     * @param  int $address_id
     * @return true|\WP_Error
     */
    public function delete_address( int $customer_id, int $address_id ) {
        $addr = $this->repo->get_address( $address_id, $customer_id );
        if ( ! $addr ) {
            return new \WP_Error( 'not_found', __( 'Address not found.', 'titancart-lite' ) );
        }

        $this->repo->delete_address( $address_id, $customer_id );

        // If we deleted the default, promote the next one.
        if ( $addr['is_default'] ?? false ) {
            $remaining = $this->repo->get_addresses( $customer_id, $addr['type'] );
            if ( ! empty( $remaining ) ) {
                $this->repo->set_default_address( $customer_id, (int) $remaining[0]['id'] );
            }
        }

        return true;
    }

    /**
     * Set an address as the default for its type.
     *
     * @param  int $customer_id
     * @param  int $address_id
     * @return true|\WP_Error
     */
    public function set_default_address( int $customer_id, int $address_id ) {
        $addr = $this->repo->get_address( $address_id, $customer_id );
        if ( ! $addr ) {
            return new \WP_Error( 'not_found', __( 'Address not found.', 'titancart-lite' ) );
        }
        $this->repo->set_default_address( $customer_id, $address_id );
        return true;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ORDER HISTORY
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Get paginated order history for a customer.
     *
     * @param  int $customer_id
     * @param  int $page
     * @param  int $per_page
     * @return array{ items: array, total: int, pages: int }
     */
    public function get_order_history( int $customer_id, int $page = 1, int $per_page = 10 ): array {
        $db       = titancart_db();
        $table    = $db->table( 'orders' );
        $per_page = max( 1, $per_page );
        $page     = max( 1, $page );
        $offset   = ( $page - 1 ) * $per_page;

        $total = (int) $db->getField(
            "SELECT COUNT(*) FROM {$table} WHERE customer_id = ?i",
            $customer_id
        );

        $items = $db->getArray(
            "SELECT o.*, os.handle AS status_handle, ost.name AS status_label, os.color AS status_color
             FROM {$table} o
             LEFT JOIN {$db->table('order_statuses')} os ON os.id = o.status_id
             LEFT JOIN {$db->table('order_status_translations')} ost
               ON ost.status_id = os.id AND ost.language_code = 'en'
             WHERE o.customer_id = ?i
             ORDER BY o.created_at DESC
             LIMIT ?i OFFSET ?i",
            $customer_id,
            $per_page,
            $offset
        );

        return [
            'items' => $items ?: [],
            'total' => $total,
            'pages' => $total > 0 ? (int) ceil( $total / $per_page ) : 1,
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // VALIDATION HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Validate address fields.
     *
     * @param  array $data
     * @return array  field => message; empty array means valid.
     */
    public function validate_address( array $data ): array {
        $errors   = [];
        $required = [ 'first_name', 'last_name', 'address_1', 'city', 'country' ];

        foreach ( $required as $field ) {
            if ( empty( trim( $data[ $field ] ?? '' ) ) ) {
                $errors[ $field ] = sprintf(
                    /* translators: %s: form field label (e.g. "First Name"). */
                    __( '%s is required.', 'titancart-lite' ),
                    ucwords( str_replace( '_', ' ', $field ) )
                );
            }
        }

        if ( ! empty( $data['country'] ) && strlen( $data['country'] ) !== 2 ) {
            $errors['country'] = __( 'Invalid country code.', 'titancart-lite' );
        }

        return $errors;
    }

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

    /**
     * Generate a unique username from the customer's name or email.
     *
     * @param  string $email
     * @param  string $first_name
     * @param  string $last_name
     * @return string
     */
    private function generate_username( string $email, string $first_name, string $last_name ): string {
        // Prefer "firstname.lastname", fall back to email prefix.
        $base = trim( $first_name ) && trim( $last_name )
            ? strtolower( sanitize_user( "{$first_name}.{$last_name}", true ) )
            : strtolower( sanitize_user( strstr( $email, '@', true ), true ) );

        $base     = $base ?: 'customer';
        $username = $base;
        $suffix   = 2;

        while ( username_exists( $username ) ) {
            $username = "{$base}{$suffix}";
            $suffix++;
        }

        return $username;
    }
}
