<?php

/**
 * Field mapping + request metadata helpers.
 *
 * Two jobs:
 *  1. Guess canonical fields (full_name, email, phone, message...) so that
 *     export and CRM get a consistent shape across totally different forms.
 *  2. Collect request metadata (ip, ua, referer, page, lang, utm).
 *
 * @package Leadite
 */
namespace Leadite\Support;

use Leadite\Storage\Lead;
defined( 'ABSPATH' ) || exit;
class FieldMapper {
    /**
     * Apply canonical guessing to every field on a lead, in place.
     *
     * @param Lead $lead Lead.
     */
    public static function apply_canonical( Lead $lead ) {
        foreach ( $lead->fields as &$field ) {
            $field['canonical'] = self::guess_canonical(
                $field['key'],
                $field['label'],
                $field['type'],
                $field['value']
            );
        }
        unset($field);
    }

    /**
     * Guess the canonical role of a field from its key/label/type/value.
     *
     * @param string $key   Field key.
     * @param string $label Field label.
     * @param string $type  Field type.
     * @param string $value Field value.
     * @return string One of: full_name|email|phone|message|company|subject|'' .
     */
    public static function guess_canonical(
        $key,
        $label,
        $type,
        $value
    ) {
        $hay = mb_strtolower( $key . ' ' . $label );
        // Strong signals from type or value first.
        if ( 'email' === $type || $value && is_email( $value ) ) {
            return 'email';
        }
        /*
         * Order matters: specific roles (company/subject/message) are tested
         * BEFORE the generic full_name, otherwise "Company Name" / "שם החברה"
         * / "اسم الشركة" would be misread as a name (the "name" needle wins).
         * Stems (компани, организаци, شرك, фамили...) handle inflected languages
         * where the full word ("компания") never appears literally ("компании").
         * Verified against a 41-case multilingual matrix (he/ru/en/ar).
         */
        $map = array(
            'email'     => array(
                'email',
                'e-mail',
                'mail',
                'почта',
                'емейл',
                'אימייל',
                'דוא',
                'بريد'
            ),
            'phone'     => array(
                'phone',
                'tel',
                'mobile',
                'whatsapp',
                'cellphone',
                'телефон',
                'моб',
                'טלפון',
                'נייד',
                'هاتف',
                'جوال',
                'رقم'
            ),
            'company'   => array(
                'company',
                'organization',
                'organisation',
                'business',
                'org',
                'компани',
                'организаци',
                'חברה',
                'شرك',
                'مؤسس'
            ),
            'subject'   => array(
                'subject',
                'regarding',
                'тема',
                'נושא',
                'موضوع'
            ),
            'message'   => array(
                'message',
                'comment',
                'enquiry',
                'inquiry',
                'сообщени',
                'коммент',
                'הודעה',
                'رسال'
            ),
            'full_name' => array(
                'full name',
                'name',
                'fullname',
                'your-name',
                'имя',
                'фамили',
                'שם',
                'الاسم',
                'اسم'
            ),
        );
        foreach ( $map as $canonical => $needles ) {
            foreach ( $needles as $needle ) {
                if ( false !== mb_strpos( $hay, $needle ) ) {
                    return $canonical;
                }
            }
        }
        if ( 'textarea' === $type ) {
            return 'message';
        }
        return '';
    }

    /**
     * Collect request metadata into the lead.
     *
     * The raw IP is stored first so that deduplication / spam-detection
     * logic operating on `$lead->meta['ip']` during the current request
     * sees the real address.  `anonymize_ip()` is called later, immediately
     * before persisting the row, ensuring the stored value is anonymised
     * when `leadite_ANONYMIZE_IP` is enabled — without affecting any
     * in-memory comparisons made earlier in the same request.
     *
     * @param Lead $lead Lead.
     */
    public static function attach_request_meta( Lead $lead ) {
        // Store the real IP now; anonymisation happens at persist-time.
        $lead->meta['ip'] = self::client_ip();
        $lead->meta['user_agent'] = ( isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '' );
        $lead->meta['referer'] = ( isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '' );
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only access to referrer during public form submission capture
        $lead->meta['page_url'] = ( isset( $_REQUEST['_wp_http_referer'] ) ? esc_url_raw( wp_unslash( $_REQUEST['_wp_http_referer'] ) ) : (( isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '' )) );
        $lead->meta['lang'] = self::detect_lang();
        // UTM capture from the referring page query string, if present.
        $utm = array();
        $ref = $lead->meta['page_url'];
        if ( $ref ) {
            $qs = wp_parse_url( $ref, PHP_URL_QUERY );
            if ( $qs ) {
                parse_str( $qs, $parsed );
                foreach ( array(
                    'utm_source',
                    'utm_medium',
                    'utm_campaign',
                    'utm_term',
                    'utm_content',
                    'gclid'
                ) as $k ) {
                    if ( !empty( $parsed[$k] ) ) {
                        $utm[$k] = sanitize_text_field( $parsed[$k] );
                    }
                }
            }
        }
        $lead->meta['utm'] = $utm;
    }

    /**
     * Best-effort client IP.
     *
     * Security model
     * --------------
     * Forwarded headers (CF-Connecting-IP, X-Forwarded-For) are trusted **only**
     * when the real TCP peer (`REMOTE_ADDR`) is listed in the
     * `leadite_TRUSTED_PROXIES` wp-config constant (comma-separated list of IPv4
     * or IPv6 CIDRs, e.g. `"103.21.244.0/22,2400:cb00::/32"` for Cloudflare).
     *
     * If the constant is not defined, or the peer is not in the trusted list,
     * `REMOTE_ADDR` is returned without consulting any forwarded headers.
     * This prevents clients from injecting arbitrary IPs on sites that are not
     * behind a known reverse proxy.
     *
     * @return string Raw (non-anonymised) IP for use during the current request.
     */
    protected static function client_ip() {
        $remote_addr = ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' );
        // Only consult forwarded headers when the peer is a trusted proxy.
        if ( defined( 'leadite_TRUSTED_PROXIES' ) && leadite_TRUSTED_PROXIES && self::ip_in_cidr_list( $remote_addr, leadite_TRUSTED_PROXIES ) ) {
            // Prefer the Cloudflare header, then the standard forwarded-for chain.
            foreach ( array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR') as $header ) {
                if ( !empty( $_SERVER[$header] ) ) {
                    $raw = sanitize_text_field( wp_unslash( $_SERVER[$header] ) );
                    // X-Forwarded-For may be a comma-separated chain; leftmost is the client.
                    $ip = trim( explode( ',', $raw )[0] );
                    if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
                        return $ip;
                    }
                }
            }
        }
        // Fallback: use the real TCP peer address.
        if ( filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
            return $remote_addr;
        }
        return '';
    }

    /**
     * Anonymise an IP address for GDPR-compliant storage.
     *
     * Called **only at persist-time** so that the raw IP remains available for
     * deduplication and spam-detection logic during the current request.
     *
     * Behaviour (controlled by `leadite_ANONYMIZE_IP` wp-config constant):
     *  - IPv4: last octet zeroed  → e.g. 203.0.113.42 → 203.0.113.0
     *  - IPv6: last 80 bits zeroed → keeps the /48 network prefix visible while
     *          making individual addresses unidentifiable.
     *  - If the constant is falsy or not defined, the original IP is returned as-is.
     *
     * @param string $ip Raw IP string.
     * @return string Anonymised (or original) IP string.
     */
    public static function anonymize_ip( $ip ) {
        // Developer override via wp-config constant takes priority over admin setting.
        // explicit false  → always store raw IP (useful for debugging/staging).
        // explicit true   → always anonymise (same as having the admin setting on).
        // constant absent → fall through to the admin setting.
        if ( defined( 'leadite_ANONYMIZE_IP' ) ) {
            if ( !leadite_ANONYMIZE_IP ) {
                return $ip;
                // Force OFF.
            }
            // If true: fall through to anonymisation below.
        } else {
            // No constant: respect the admin setting (default: true).
            if ( !\Leadite\Config::get( 'anonymize_ip', true ) ) {
                return $ip;
            }
        }
        if ( !$ip ) {
            return $ip;
        }
        // IPv6.
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
            $packed = inet_pton( $ip );
            if ( false === $packed ) {
                return $ip;
            }
            // Zero the last 10 bytes (80 bits), keeping the top 48-bit prefix.
            $anonymised = substr( $packed, 0, 6 ) . str_repeat( "\x00", 10 );
            $result = inet_ntop( $anonymised );
            return ( $result !== false ? $result : $ip );
        }
        // IPv4: zero the last octet.
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            $parts = explode( '.', $ip );
            $parts[3] = '0';
            return implode( '.', $parts );
        }
        return $ip;
    }

    /**
     * Check whether an IP address falls within a comma-separated list of CIDR ranges.
     *
     * Accepts both IPv4 and IPv6 CIDRs.
     *
     * @param string $ip    IP address to test.
     * @param string $cidrs Comma-separated CIDR list, e.g. "10.0.0.0/8,::1/128".
     * @return bool True if the IP is within any of the listed ranges.
     */
    protected static function ip_in_cidr_list( $ip, $cidrs ) {
        if ( !filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return false;
        }
        foreach ( array_map( 'trim', explode( ',', $cidrs ) ) as $cidr ) {
            if ( '' === $cidr ) {
                continue;
            }
            // Handle bare IPs (no prefix) as /32 or /128.
            if ( strpos( $cidr, '/' ) === false ) {
                if ( $ip === $cidr ) {
                    return true;
                }
                continue;
            }
            list( $network, $prefix ) = explode( '/', $cidr, 2 );
            $prefix = (int) $prefix;
            // IPv6 CIDR.
            if ( filter_var( $network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
                if ( !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
                    continue;
                }
                $packed_ip = inet_pton( $ip );
                $packed_net = inet_pton( $network );
                if ( false === $packed_ip || false === $packed_net ) {
                    continue;
                }
                // Compare bit-by-bit for the specified prefix length.
                $full_bytes = intdiv( $prefix, 8 );
                $rem_bits = $prefix % 8;
                if ( substr( $packed_ip, 0, $full_bytes ) !== substr( $packed_net, 0, $full_bytes ) ) {
                    continue;
                }
                if ( $rem_bits > 0 && $full_bytes < 16 ) {
                    $mask = 0xff & 0xff << 8 - $rem_bits;
                    if ( (ord( $packed_ip[$full_bytes] ) & $mask) !== (ord( $packed_net[$full_bytes] ) & $mask) ) {
                        continue;
                    }
                }
                return true;
            }
            // IPv4 CIDR.
            if ( filter_var( $network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
                if ( !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
                    continue;
                }
                $mask = ( $prefix === 0 ? 0 : ~((1 << 32 - $prefix) - 1) & 0xffffffff );
                $ip_long = ip2long( $ip );
                $net_long = ip2long( $network );
                if ( ($ip_long & $mask) === ($net_long & $mask) ) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Detect content language (WPML/Polylang aware, falls back to site locale).
     *
     * @return string
     */
    public static function detect_lang() {
        if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {
            return (string) ICL_LANGUAGE_CODE;
        }
        if ( function_exists( 'pll_current_language' ) ) {
            $pll = pll_current_language();
            if ( $pll ) {
                return (string) $pll;
            }
        }
        return substr( (string) get_locale(), 0, 5 );
    }

    /**
     * Pull a canonical value out of a lead's fields (first match).
     *
     * @param object[] $fields  Meta rows (from repository) or lead field arrays.
     * @param string   $canonical Canonical key.
     * @return string
     */
    public static function canonical_value( $fields, $canonical ) {
        foreach ( (array) $fields as $f ) {
            $c = ( is_array( $f ) ? $f['canonical'] ?? '' : $f->canonical ?? '' );
            if ( $c === $canonical ) {
                return ( is_array( $f ) ? (string) $f['value'] : (string) $f->field_value );
            }
        }
        return '';
    }

    /**
     * Normalize form ID by stripping _migrated_<digits> suffix.
     *
     * @param string $form_id Form identifier.
     * @return string Normalized base form ID.
     */
    public static function normalize_form_id( string $form_id ) : string {
        return preg_replace( '/_migrated_\\d+$/', '', $form_id );
    }

    /**
     * Load saved field-map overrides for a specific (source, form_id) pair.
     *
     * Returns an associative array keyed by field_key with the saved canonical
     * as value.  An empty array is returned when no overrides exist.
     *
     * This is a READ-ONLY operation: it never modifies stored lead meta.
     *
     * @param string $source  Lead source slug (e.g. 'cf7').
     * @param string $form_id Form identifier.
     * @return array<string,string>  ['field_key' => 'canonical', ...]
     */
    public static function get_map( string $source, string $form_id ) : array {
        return array();
    }

    /**
     * Persist field-map overrides for a (source, form_id) pair.
     *
     * Accepts an array of ['field_key' => 'canonical'] pairs.
     * Rows with an empty canonical are treated as "reset to auto" and deleted.
     * Existing rows not present in $mappings are left untouched.
     *
     * This is an admin-only write operation (Pro-gated by the caller).
     *
     * @param string              $source   Lead source slug.
     * @param string              $form_id  Form identifier.
     * @param array<string,string> $mappings ['field_key' => 'canonical', ...]
     * @return void
     */
    public static function save_map( string $source, string $form_id, array $mappings ) : void {
    }

    /**
     * Resolve the effective canonical for a single field, respecting any saved
     * override in leadite_field_map.
     *
     * Priority: saved override > stored meta canonical > auto-guess.
     * This is READ-ONLY: it never writes back to leadite_meta.
     *
     * @param string $source    Lead source slug.
     * @param string $form_id   Form identifier.
     * @param string $field_key Raw field key.
     * @param string $stored_canonical The canonical value already stored in meta.
     * @param string $label     Field label (used for auto-guess fallback).
     * @param string $type      Field type (used for auto-guess fallback).
     * @param string $value     Field value (used for auto-guess fallback).
     * @return string Effective canonical.
     */
    public static function effective_canonical(
        string $source,
        string $form_id,
        string $field_key,
        string $stored_canonical,
        string $label = '',
        string $type = '',
        string $value = ''
    ) : string {
        if ( '' !== $stored_canonical ) {
            return $stored_canonical;
            // Stored value from insert-time.
        }
        // Last resort: re-run auto-guess (rare — only when canonical column was empty at insert).
        return self::guess_canonical(
            $field_key,
            $label,
            $type,
            $value
        );
    }

}
