<?php
/**
 * CustomerFieldsManager — read/write layer for non-system customer field values.
 *
 * The admin Customer Fields page (REST: CustomerFieldsController) defines
 * which fields exist per section (shipping/contact/billing). System fields
 * (first_name, last_name, email, phone, address_1, etc.) are stored in
 * tc_customers and tc_customer_addresses fixed columns. Non-system custom
 * fields (e.g. "Tax ID") have no fixed column — their values land in
 * tc_customer_field_values, keyed by (customer_id, field_id).
 *
 * This manager is the single read/write surface for those custom values. It
 * does NOT touch system fields — those continue to flow through the existing
 * AccountManager / address book write paths.
 *
 * Responsibilities:
 *   - List visible custom fields per section (admin gate respected).
 *   - Load all stored values for a given customer.
 *   - Validate required fields against a submitted set.
 *   - Sanitize and persist a batch of submitted values.
 *   - Extract submitted values from a $_POST-style array using the
 *     `tc_cf_<field_id>` convention.
 *
 * Wired by S127.C-1.
 *
 * @package TitanCart\Modules\Accounts
 */

namespace TitanCart\Modules\Accounts;

defined( 'ABSPATH' ) || exit;

class CustomerFieldsManager {

    /** Allowed section identifiers. */
    private const SECTIONS = [ 'contact', 'shipping', 'billing' ];

    /** Form-field name prefix for custom fields in submitted forms. */
    public const POST_KEY_PREFIX = 'tc_cf_';

    /**
     * Field names that map to native columns (or wp_user_meta) rather than
     * to tc_customer_field_values, keyed by section.
     *
     * The tc_customer_fields table holds two conceptually-different kinds
     * of rows for non-system fields (`is_system = 0`):
     *
     *   1. **Native-column proxies.** Rows like contact.phone, contact.company,
     *      shipping.address_2 etc. exist purely so the admin can toggle
     *      visibility / required-ness for fields whose data lives in a
     *      *native* column (tc_customers.phone, wp_user_meta tc_company,
     *      tc_customer_addresses.address_2, …). They must NOT be rendered
     *      again by render_section_html — the storefront templates already
     *      have native inputs for those, and rendering both produces
     *      duplicate fields + double-write surface in account.js's
     *      tc_cf_* collection loop.
     *
     *   2. **True custom fields.** Rows like contact.tax_id (or anything an
     *      admin adds via the "+ Add custom field" UI) have no native
     *      column at all. Their values live in tc_customer_field_values
     *      keyed by field_id. These are the only rows render_section_html
     *      should emit.
     *
     * Distinguishing them by `is_system` alone is insufficient — most
     * native-column proxies were seeded with `is_system = 0` because they
     * ARE admin-deletable. The discriminator that actually matters is the
     * field_name: if it matches a known native column for its section,
     * the row is a configurability proxy and we filter it out here.
     *
     * Visibility/required toggles for native-column proxies still feed the
     * storefront via the dedicated configuration code path — they just
     * don't go through the generic custom-field renderer.
     *
     * @var array<string,string[]>
     */
    private const NATIVE_FIELD_NAMES = [
        'contact'  => [ 'first_name', 'last_name', 'email', 'phone', 'company' ],
        'shipping' => [ 'first_name', 'last_name', 'address_1', 'address_2', 'city', 'state', 'country', 'zipcode', 'phone', 'address_type' ],
        'billing'  => [ 'first_name', 'last_name', 'address_1', 'address_2', 'city', 'state', 'country', 'zipcode', 'phone', 'address_type' ],
    ];

    // ══════════════════════════════════════════════════════════════════════════
    // FIELD DEFINITION READS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Get visible non-system custom fields for a section.
     *
     * Respects the admin's `is_visible` toggle — admin owns the gate. Returns
     * empty array for unknown sections so callers never have to guard.
     *
     * Native-column proxy rows (see NATIVE_FIELD_NAMES docblock) are
     * filtered out here so the generic renderer never duplicates a
     * native input.
     *
     * @param  string $section  'contact'|'shipping'|'billing'
     * @return array  Field rows ordered by position then id.
     */
    public function get_visible_custom_fields( string $section ): array {
        if ( ! in_array( $section, self::SECTIONS, true ) ) {
            return [];
        }

        $db   = titancart_db();
        $rows = $db->getArray(
            "SELECT * FROM {$db->table('customer_fields')}
              WHERE section = ?s
                AND is_visible = 1
                AND is_system = 0
                AND status = 'active'
              ORDER BY position ASC, id ASC",
            $section
        ) ?: [];

        $native = self::NATIVE_FIELD_NAMES[ $section ] ?? [];
        if ( empty( $native ) ) {
            return $rows;
        }

        return array_values( array_filter(
            $rows,
            static fn( array $row ) => ! in_array( (string) ( $row['field_name'] ?? '' ), $native, true )
        ) );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // VALUE READS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Get every stored custom field value for a customer.
     *
     * @param  int $customer_id
     * @return array<int,string>  Map of field_id => value (string).
     */
    public function get_values( int $customer_id ): array {
        if ( $customer_id <= 0 ) {
            return [];
        }

        $db   = titancart_db();
        $rows = $db->getArray(
            "SELECT field_id, value FROM {$db->table('customer_field_values')}
              WHERE customer_id = ?i",
            $customer_id
        ) ?: [];

        $map = [];
        foreach ( $rows as $r ) {
            $map[ (int) $r['field_id'] ] = (string) $r['value'];
        }
        return $map;
    }

    /**
     * Convenience for templates — pair each visible custom field row with
     * its currently-stored value (empty string when absent).
     *
     * @param  string $section
     * @param  int    $customer_id  0 for guests / new registrations.
     * @return array  Array of [ 'field' => row, 'value' => string ].
     */
    public function fields_with_values( string $section, int $customer_id = 0 ): array {
        $fields = $this->get_visible_custom_fields( $section );
        if ( empty( $fields ) ) {
            return [];
        }

        $values = $customer_id > 0 ? $this->get_values( $customer_id ) : [];

        $out = [];
        foreach ( $fields as $f ) {
            $out[] = [
                'field' => $f,
                'value' => $values[ (int) $f['id'] ] ?? '',
            ];
        }
        return $out;
    }

    /**
     * Render a single custom field as HTML for use in storefront forms.
     *
     * Output shape mirrors the existing tc-form pattern in account templates:
     *   <div class="{wrapper_class}">
     *     <label for="...">Description [*]</label>
     *     <input ... />
     *   </div>
     *
     * Form input names follow the `tc_cf_<field_id>` convention so callers
     * can extract them via extract_submitted(). For checkbox type, a hidden
     * companion input with value=0 is emitted BEFORE the checkbox so that
     * unchecked submissions still arrive in $_POST as '0'.
     *
     * Returns an HTML string (already escaped). Caller is responsible for
     * placing it inside an existing <form>.
     *
     * @param  array  $field    Row from tc_customer_fields.
     * @param  string $value    Pre-fill value (raw, will be esc_attr'd).
     * @param  string $id_prefix  HTML id prefix to keep ids unique per form
     *                            (e.g. 'tc-reg-cf-' for register, 'tc-prof-cf-'
     *                            for profile editor).
     * @param  string $wrapper_class  CSS class on the wrapping <div>. Defaults
     *                                to 'tc-form__row' which matches the
     *                                plugin's account/* templates. Pass
     *                                'tc-field' for the all-in-one dashboard.php
     *                                template. Special variant for checkbox
     *                                rows uses '{wrapper_class}--checkbox'
     *                                modifier when the wrapper is the BEM
     *                                'tc-form__row'; for non-BEM wrappers it
     *                                falls back to the bare wrapper class.
     * @param  string $input_class    CSS class on the <input>/<textarea>.
     *                                Defaults to 'tc-input'. Pass empty string
     *                                for unstyled inputs (matches dashboard.php).
     * @return string  HTML string.
     */
    public function render_field_html( array $field, string $value, string $id_prefix, string $wrapper_class = 'tc-form__row', string $input_class = 'tc-input' ): string {
        $fid          = (int) ( $field['id'] ?? 0 );
        if ( $fid <= 0 ) {
            return '';
        }

        $type         = (string) ( $field['field_type'] ?? 'input' );
        $name         = self::POST_KEY_PREFIX . $fid;
        $html_id      = $id_prefix . $fid;
        $label        = (string) ( $field['description'] !== '' ? $field['description'] : $field['field_name'] );
        $is_required  = ! empty( $field['is_required'] );

        $required_attr   = $is_required ? ' required' : '';
        $required_marker = $is_required ? ' <span aria-hidden="true">*</span>' : '';
        $class_attr      = $input_class !== '' ? ' class="' . esc_attr( $input_class ) . '"' : '';

        $label_html = '<label for="' . esc_attr( $html_id ) . '">'
            . esc_html( $label )
            . $required_marker
            . '</label>';

        switch ( $type ) {
            case 'checkbox':
                // Hidden companion BEFORE checkbox guarantees unchecked still
                // submits as 0 — relies on PHP keeping the LAST same-name
                // value from $_POST.
                $checked    = ! empty( $value ) && $value !== '0' ? ' checked' : '';
                $input_html = '<input type="hidden" name="' . esc_attr( $name ) . '" value="0" />'
                    . '<input id="' . esc_attr( $html_id ) . '"'
                    . ' type="checkbox"'
                    . ' name="' . esc_attr( $name ) . '"'
                    . ' value="1"'
                    . $checked
                    . $required_attr
                    . ' />';
                // Checkbox layouts conventionally pair the input next to its
                // label rather than label-above-input. Modifier class only
                // applied for the BEM wrapper; raw wrappers stay untouched.
                $cb_wrapper = $wrapper_class === 'tc-form__row'
                    ? $wrapper_class . ' tc-form__row--checkbox'
                    : $wrapper_class;
                return '<div class="' . esc_attr( $cb_wrapper ) . '">'
                    . '<label>' . $input_html . ' ' . esc_html( $label ) . $required_marker . '</label>'
                    . '</div>';

            case 'textarea':
                $input_html = '<textarea id="' . esc_attr( $html_id ) . '"'
                    . ' name="' . esc_attr( $name ) . '"'
                    . $class_attr
                    . ' rows="3"'
                    . $required_attr
                    . '>' . esc_textarea( $value ) . '</textarea>';
                break;

            case 'email':
                $input_html = '<input id="' . esc_attr( $html_id ) . '"'
                    . ' type="email"'
                    . ' name="' . esc_attr( $name ) . '"'
                    . $class_attr
                    . ' value="' . esc_attr( $value ) . '"'
                    . $required_attr
                    . ' />';
                break;

            case 'phone':
                $input_html = '<input id="' . esc_attr( $html_id ) . '"'
                    . ' type="tel"'
                    . ' name="' . esc_attr( $name ) . '"'
                    . $class_attr
                    . ' value="' . esc_attr( $value ) . '"'
                    . $required_attr
                    . ' />';
                break;

            case 'input':
            default:
                $input_html = '<input id="' . esc_attr( $html_id ) . '"'
                    . ' type="text"'
                    . ' name="' . esc_attr( $name ) . '"'
                    . $class_attr
                    . ' value="' . esc_attr( $value ) . '"'
                    . $required_attr
                    . ' />';
                break;
        }

        return '<div class="' . esc_attr( $wrapper_class ) . '">' . $label_html . $input_html . '</div>';
    }

    /**
     * Render every visible custom field for a section as HTML.
     *
     * @param  string $section
     * @param  int    $customer_id  0 = no values prefilled.
     * @param  string $id_prefix
     * @param  array  $value_overrides  Map of field_id => string. Used to
     *                                  preserve user-submitted values when a
     *                                  validation error forces re-render of
     *                                  the form.
     * @param  string $wrapper_class    Forwarded to render_field_html.
     * @param  string $input_class      Forwarded to render_field_html.
     * @return string
     */
    /**
     * WP.org R2 item 6: form-field allowlist for this manager's rendered markup.
     * Every echo site of render_section_html() must pass its output through
     * `wp_kses( $html, CustomerFieldsManager::form_kses_allowed() )` — wp_kses()
     * at the echo itself so the escaping sniff recognises it (defense-in-depth
     * at the output boundary; the builder already escapes all dynamic values
     * via esc_attr/esc_html). kses_form() remains as a convenience for callers
     * that are not themselves echo sites.
     *
     * @return array<string,array<string,bool>>
     */
    public static function form_kses_allowed(): array {
        $common = [ 'id' => true, 'class' => true, 'style' => true, 'data-*' => true ];
        return [
            'div'      => $common,
            'span'     => $common + [ 'aria-hidden' => true ],
            'label'    => $common + [ 'for' => true ],
            'input'    => $common + [ 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'required' => true, 'checked' => true, 'readonly' => true, 'disabled' => true, 'autocomplete' => true, 'maxlength' => true, 'pattern' => true, 'inputmode' => true, 'min' => true, 'max' => true, 'step' => true ],
            'textarea' => $common + [ 'name' => true, 'rows' => true, 'cols' => true, 'placeholder' => true, 'required' => true, 'readonly' => true, 'disabled' => true, 'maxlength' => true ],
            'select'   => $common + [ 'name' => true, 'required' => true, 'disabled' => true, 'multiple' => true ],
            'option'   => $common + [ 'value' => true, 'selected' => true, 'disabled' => true ],
            'fieldset' => $common,
            'legend'   => $common,
        ];
    }

    /**
     * @param  string $html Markup produced by render_section_html().
     * @return string
     */
    public static function kses_form( string $html ): string {
        return wp_kses( $html, self::form_kses_allowed() );
    }

    public function render_section_html( string $section, int $customer_id, string $id_prefix, array $value_overrides = [], string $wrapper_class = 'tc-form__row', string $input_class = 'tc-input' ): string {
        $pairs = $this->fields_with_values( $section, $customer_id );
        if ( empty( $pairs ) ) {
            return '';
        }

        $out = '';
        foreach ( $pairs as $pair ) {
            $field = $pair['field'];
            $fid   = (int) $field['id'];
            $value = array_key_exists( $fid, $value_overrides )
                ? (string) $value_overrides[ $fid ]
                : (string) $pair['value'];
            $out  .= $this->render_field_html( $field, $value, $id_prefix, $wrapper_class, $input_class );
        }
        return $out;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // SUBMIT HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Pull custom-field values out of a $_POST-style array.
     *
     * Form inputs follow the convention `tc_cf_<field_id>`. Returns a clean
     * field_id => raw_value map; non-matching keys are ignored. The raw values
     * are NOT yet sanitized — callers should persist via persist_section_values()
     * or sanitize before display.
     *
     * @param  array $post
     * @return array<int,mixed>
     */
    public function extract_submitted( array $post ): array {
        $out    = [];
        $prefix = self::POST_KEY_PREFIX;
        $plen   = strlen( $prefix );

        foreach ( $post as $key => $value ) {
            if ( ! is_string( $key ) || strncmp( $key, $prefix, $plen ) !== 0 ) {
                continue;
            }
            $fid = (int) substr( $key, $plen );
            if ( $fid > 0 ) {
                // WP.org R1 🅱2: unslash + sanitize at the extraction seam —
                // callers pass raw $_POST. Arrays (multi-checkbox) sanitize
                // per element; scalars via sanitize_text_field.
                $value = wp_unslash( $value );
                $out[ $fid ] = is_array( $value )
                    ? array_map( 'sanitize_text_field', $value )
                    : sanitize_text_field( (string) $value );
            }
        }
        return $out;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // VALIDATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Validate that all visible+required custom fields for a section have
     * non-empty submitted values.
     *
     * Checkbox required = must be checked. Other types required = must trim
     * to a non-empty string. Returns a field_id => message map; empty array
     * means valid.
     *
     * @param  string $section
     * @param  array  $submitted  Map of field_id => raw value.
     * @return array<int,string>
     */
    public function validate_required( string $section, array $submitted ): array {
        $errors = [];
        $fields = $this->get_visible_custom_fields( $section );

        foreach ( $fields as $field ) {
            if ( empty( $field['is_required'] ) ) {
                continue;
            }

            $fid   = (int) $field['id'];
            $type  = (string) ( $field['field_type'] ?? 'input' );
            $value = $submitted[ $fid ] ?? '';
            $label = $field['description'] !== '' ? $field['description'] : $field['field_name'];

            $missing = $type === 'checkbox'
                ? empty( $value )
                : trim( (string) $value ) === '';

            if ( $missing ) {
                $errors[ $fid ] = sprintf(
                    /* translators: %s: field label */
                    __( '%s is required.', 'titancart-lite' ),
                    $label
                );
            }
        }

        return $errors;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // VALUE WRITES
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * UPSERT a single field value.
     *
     * Uses MySQL's ON DUPLICATE KEY UPDATE on the (customer_id, field_id)
     * uniqueness constraint, so a customer can only have one value per field.
     *
     * @param  int    $customer_id
     * @param  int    $field_id
     * @param  string $value
     * @return void
     */
    public function set_value( int $customer_id, int $field_id, string $value ): void {
        if ( $customer_id <= 0 || $field_id <= 0 ) {
            return;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'tc_customer_field_values';
        $wpdb->query( $wpdb->prepare(
            "INSERT INTO {$table} (customer_id, field_id, value)
             VALUES (%d, %d, %s)
             ON DUPLICATE KEY UPDATE value = VALUES(value)",
            $customer_id,
            $field_id,
            $value
        ) );
    }

    /**
     * Sanitize and persist a batch of submitted custom-field values for a
     * specific section.
     *
     * Only fields that are visible+custom for the section are written —
     * stray submitted keys for hidden or system fields are dropped. Fields
     * not present in the submitted map are left untouched (we never wipe
     * an existing value just because the form omitted the input).
     *
     * Checkbox fields are handled at template level via a hidden=0 input
     * placed BEFORE the checkbox=1, which guarantees the field is always
     * present in $_POST when the form was rendered. So no special-case is
     * needed here — an absent key truly means "form did not render this
     * field, leave it alone".
     *
     * @param  int    $customer_id
     * @param  string $section
     * @param  array  $submitted  Map of field_id => raw value.
     * @return void
     */
    public function persist_section_values( int $customer_id, string $section, array $submitted ): void {
        if ( $customer_id <= 0 ) {
            return;
        }

        $fields = $this->get_visible_custom_fields( $section );
        foreach ( $fields as $field ) {
            $fid  = (int) $field['id'];
            $type = (string) ( $field['field_type'] ?? 'input' );

            if ( ! array_key_exists( $fid, $submitted ) ) {
                // Form did not render or did not submit this field — never
                // wipe a stored value just because the input was absent.
                continue;
            }

            $clean = $this->sanitize_value( $submitted[ $fid ], $type );
            $this->set_value( $customer_id, $fid, $clean );
        }
    }

    /**
     * Delete every custom field value belonging to a customer.
     *
     * Used during account deletion to keep the values table clean.
     *
     * @param  int $customer_id
     * @return void
     */
    public function delete_all_for_customer( int $customer_id ): void {
        if ( $customer_id <= 0 ) {
            return;
        }

        global $wpdb;
        $wpdb->delete(
            $wpdb->prefix . 'tc_customer_field_values',
            [ 'customer_id' => $customer_id ]
        );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // INTERNAL
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Sanitize a raw input value based on the declared field_type.
     *
     * Unknown types fall back to plain text input.
     */
    private function sanitize_value( $raw, string $type ): string {
        switch ( $type ) {
            case 'textarea':
                return sanitize_textarea_field( (string) $raw );

            case 'checkbox':
                return ! empty( $raw ) ? '1' : '0';

            case 'email':
                return sanitize_email( (string) $raw );

            case 'phone':
            case 'input':
            default:
                return sanitize_text_field( (string) $raw );
        }
    }
}
