"""Data type classifier for column-level type inference.

Provides :class:`DataType` enumeration and functions to classify Polars
columns based on name heuristics and value sampling.

Typical usage::

    from analysis.type_classifier import classify_column, classify_schema, DataType

    # Single column
    dtype = classify_column('src_ip', series, config_type='ipv4')

    # Full schema
    type_map = classify_schema(lazyframe, config_overrides={'src_ip': 'ipv4'})
"""
from __future__ import annotations

import re
from enum import Enum

import polars as pl

from analysis.tls_ref import get_tls_ref_dict

# ---------------------------------------------------------------------------
#  TLS version hex-to-label mapping
#  (Duplicated in views.py for globe visualization — keep both in sync.)
# ---------------------------------------------------------------------------

TLS_HEX_MAP: dict[str, str] = {
    '0303': 'TLSv1.2',
    '0304': 'TLSv1.3',
    '0302': 'TLSv1.1',
    '0301': 'TLSv1.0',
}

# ---------------------------------------------------------------------------
#  DataType enumeration
# ---------------------------------------------------------------------------


class DataType(Enum):
    """Enumerated column types recognised by the classifier engine."""

    INT = 1
    FLOAT = 2
    ENUM = 3
    HEX = 4
    URL = 5
    IPv4 = 6
    LAT_LON = 7
    TIMESTAMP = 8
    BOOL_ENUM = 9
    STRING = 10


# ---------------------------------------------------------------------------
#  Detection patterns (raw strings for regex)
# ---------------------------------------------------------------------------

HEX_PATTERN = r'^[0-9a-f]{2}(?:\s[0-9a-f]{2})*$'
"""Matches ``"0e a6 3f"``, ``"ab cd ef"`` etc. (space-separated hex pairs)."""

URL_PATTERN = r'^https?://'
"""Matches URLs starting with ``http://`` or ``https://``."""

IPv4_PATTERN = r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
"""Matches ``"192.168.1.1"`` style IPv4 addresses."""

MAC_PATTERN = r'^([0-9A-Fa-f]{2}[:.-]){5}[0-9A-Fa-f]{2}$'
"""Matches MAC addresses with ``:``, ``-``, or ``.`` separators."""

BOOL_ENUM_VALUES = (
    '', ' ', '+', '-', '0', '1',
    'true', 'false', 'yes', 'no', 't', 'f',
)
"""String values considered boolean / binary indicators."""

LAT_LON_NAMES = ('lat', 'latitude', 'lon', 'lng', 'longitude')

# Name-to-type heuristics (checked after config override, before values)
_NAME_TYPE_MAP: list[tuple[re.Pattern, DataType]] = [
    # User column names - EXACT match only (no generic fuzzy patterns)
    # IPv4 columns
    (re.compile(r'^:ips$|^:ipd$|^server.ip$|^client.ip$', re.I), DataType.IPv4),
    # ENUM columns
    (re.compile(r'^:prs$|^:prd$', re.I), DataType.ENUM),
    (re.compile(r'^(?:cnrs|isrs)$', re.I), DataType.BOOL_ENUM),
    (re.compile(r'^(?:scnt|dcnt|1ipp|4dbn|4srs|eiph)$', re.I), DataType.ENUM),
    (re.compile(r'^0ver$', re.I), DataType.ENUM),
    # FLOAT columns
    (re.compile(r'^4dur$|^8ses$|^2tmo$|^0rnt$', re.I), DataType.FLOAT),
    # INT columns
    (re.compile(r'^4ksz$|^8ack$|^8ppk$|^8pak$|^8seq$|^8did$|^8byt$|^row$', re.I), DataType.INT),
    # HEX columns
    (re.compile(r'^0cph$|^0crv$', re.I), DataType.HEX),
    # TIMESTAMP columns
    (re.compile(r'^8dbd$|^time$|^timestamp$', re.I), DataType.TIMESTAMP),
    # Dot-notation suffixes for lat/lon (e.g. :ips.latd, :ipd.lond)
    (re.compile(r'\.latd$', re.I), DataType.LAT_LON),
    (re.compile(r'\.lond$', re.I), DataType.LAT_LON),
    # Dot-notation STRING suffixes (e.g. :ips.ispn, :ipd.city)
    (re.compile(r'\.(ispn|orgn|city|anon)$', re.I), DataType.STRING),
    # Standalone string columns - exact match
    (re.compile(r'^(?:cnam|snam|tabl|name|0rnd|source.node|ecdhe.named.curve|crcc|orga|orgu)$', re.I), DataType.STRING),
    # Special columns with @ prefix
    (re.compile(r'^@', re.I), DataType.STRING),
]

# Mapping from config.yaml type strings to DataType
_CONFIG_TYPE_MAP: dict[str, DataType] = {
    'int': DataType.INT,
    'float': DataType.FLOAT,
    'enum': DataType.ENUM,
    'hex': DataType.HEX,
    'url': DataType.URL,
    'ipv4': DataType.IPv4,
    'lat_lon': DataType.LAT_LON,
    'timestamp': DataType.TIMESTAMP,
    'bool_enum': DataType.BOOL_ENUM,
    'string': DataType.STRING,
}

# Mapping from tls_ref_data ``data_type`` column values to DataType
TLSDB_TYPE_MAP: dict[str, DataType] = {
    'IPv4': DataType.IPv4,
    '浮点': DataType.FLOAT,
    '字符串': DataType.STRING,
    '整数': DataType.INT,
    '2小写字母缩写': DataType.ENUM,
    'XX:YY.Z': DataType.STRING,
    '2字节': DataType.HEX,
    '2大写字母': DataType.STRING,
    '枚举': DataType.ENUM,
    '布尔': DataType.BOOL_ENUM,
    '字符串或整数': DataType.STRING,
}


def _tlsdb_type_to_datatype(tlsdb_type_str: str) -> DataType | None:
    """Convert a TlsDB ``data_type`` string to a :class:`DataType`.

    Returns ``None`` if the type string is unknown, allowing the
    caller to fall through to value-based detection.
    """
    return TLSDB_TYPE_MAP.get(tlsdb_type_str)


# ---------------------------------------------------------------------------
#  Column classification (single column)
# ---------------------------------------------------------------------------


def _name_to_type(name: str) -> DataType | None:
    """Match column name against known heuristics.

    Returns a :class:`DataType` if the name matches strongly, otherwise
    ``None`` (fall through to value-based detection).
    """
    for pattern, dtype in _NAME_TYPE_MAP:
        if pattern.search(name):
            return dtype
    return None


def _is_port_value(v: str) -> bool:
    """Check if *v* is an integer string in the registered port range (1-65535)."""
    try:
        n = int(v)
        return 1 <= n <= 65535
    except ValueError:
        return False


def _can_float(v: str) -> bool:
    """Return ``True`` when *v* can be parsed as a Python float."""
    try:
        float(v)
        return True
    except ValueError:
        return False


def _values_to_type(series: pl.Series, col_name: str | None = None) -> DataType:
    """Infer column type by sampling non-null values.

    Detection order: IPv4 → MAC guard → HEX → URL → PORT → BOOL_ENUM
                     → LAT_LON → ENUM → FLOAT → STRING
    """
    dtype = series.dtype

    # Boolean dtype ──────────────────────────────────────────────────────
    if dtype == pl.Boolean:
        return DataType.BOOL_ENUM

    # Numeric dtypes ─────────────────────────────────────────────────────
    if dtype in (pl.Float32, pl.Float64):
        # Check LAT_LON possibility before returning FLOAT
        non_null = series.drop_nulls()
        if len(non_null) > 0:
            sample = non_null.head(100)
            sample_strs = [str(v).strip().lower() for v in sample]
            non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
            if non_blank:
                try:
                    floats = [float(v) for v in non_blank]
                    max_abs = max(abs(f) for f in floats)
                    if (
                        all(-180 <= f <= 180 for f in floats)
                        and any('.' in v for v in non_blank)
                        and max_abs > 10
                    ):
                        return DataType.LAT_LON
                except ValueError:
                    pass
        return DataType.FLOAT

    if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
                 pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64):
        return DataType.INT

    # String / categorical types ─────────────────────────────────────────
    non_null = series.drop_nulls()
    if len(non_null) == 0:
        return DataType.STRING

    sample = non_null.head(100)
    # Convert all to string representations for pattern matching
    sample_strs = [str(v).strip().lower() for v in sample]

    # ── 1. IPv4 detection ──────────────────────────────────────────────
    if all(re.match(IPv4_PATTERN, v) for v in sample_strs):
        if all(_valid_ip_octets(v) for v in sample_strs):
            return DataType.IPv4

    # ── 2. MAC guard (skip HEX if values look like MAC addresses) ──────
    is_mac = all(re.match(MAC_PATTERN, v) for v in sample_strs)

    # ── 3. HEX detection ───────────────────────────────────────────────
    if not is_mac and all(re.match(HEX_PATTERN, v) for v in sample_strs):
        return DataType.HEX

    # ── 4. URL detection ───────────────────────────────────────────────
    if all(re.match(URL_PATTERN, v) for v in sample_strs):
        return DataType.URL

    # ── 5. PORT detection → ENUM ───────────────────────────────────────
    if all(_is_port_value(v) for v in sample_strs):
        return DataType.ENUM

    # ── 6. BOOL_ENUM detection ─────────────────────────────────────────
    if all(v in BOOL_ENUM_VALUES for v in sample_strs):
        return DataType.BOOL_ENUM

    # ── 7. LAT_LON detection (value-based) ──────────────────────────────
    non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
    if non_blank:
        try:
            floats = [float(v) for v in non_blank]
            max_abs = max(abs(f) for f in floats)
            if (
                all(-180 <= f <= 180 for f in floats)
                and any('.' in v for v in non_blank)
                and max_abs > 10
            ):
                return DataType.LAT_LON
        except ValueError:
            pass

    # ── 8. ENUM detection (few unique values) ──────────────────────────
    unique_count = non_null.n_unique()
    if unique_count < 20:
        # Safety: if >80 % of the sample values are unique strings,
        # the column is high-cardinality — classify as STRING, not ENUM.
        sample_unique_count = len(set(sample_strs))
        if sample_unique_count > 0.8 * len(sample_strs):
            return DataType.STRING
        return DataType.ENUM

    # ── 9. TLS version hex detection (before STRING fallback) ──────────
    if col_name is not None and col_name.lower() in ('0ver', '_tlsver', 'tls_version', 'version'):
        sample_hex = sample_strs[0].replace(' ', '').strip()
        if sample_hex.upper() in TLS_HEX_MAP:
            return DataType.ENUM

    # ── 10. FLOAT detection (>80 % can float) ──────────────────────────
    # Exclude strings starting with '+' from FLOAT detection — these are
    # text markers (phone codes, IDs, error codes), not real numbers.
    if any(v.startswith('+') for v in sample_strs):
        return DataType.STRING

    float_count = sum(1 for v in sample_strs if _can_float(v))
    if float_count / len(sample_strs) > 0.8:
        # Guard: values with leading "-" that happen to parse as float
        # should not force FLOAT when non-float values also exist.
        if any(v.startswith('-') for v in sample_strs):
            if float_count < len(sample_strs):
                return DataType.STRING
        return DataType.FLOAT

    return DataType.STRING


def _valid_ip_octets(ip_str: str) -> bool:
    """Check that each octet in an IPv4 string is 0-255."""
    parts = ip_str.split('.')
    if len(parts) != 4:
        return False
    for p in parts:
        try:
            n = int(p)
            if n < 0 or n > 255:
                return False
        except ValueError:
            return False
    return True


def classify_column(
    name: str,
    series: pl.Series,
    config_type: str | None = None,
) -> DataType:
    """Classify a single column based on its *name* and *series* values.

    Priority
    --------
    1. **config_type** — user override from ``config.yaml``.
       If provided (e.g. ``'ipv4'``), returns the corresponding
       :class:`DataType` immediately.
    2. **tls_ref_data** — authoritative type from ``TlsDB.csv`` /
       ``tls_ref_data`` table.  Looked up by column name; if found,
       the declared type is used directly (no value-based guessing).
    3. **Value-based** pattern matching — samples up to 100 non-null
       values and tests against IPv4 → HEX → URL → PORT → BOOL_ENUM
       → LAT_LON → ENUM → FLOAT in order.
    4. **Name-based** heuristics — only consulted when value-based
       detection returned ``STRING`` (i.e. no value pattern matched).

    Parameters
    ----------
    name:
        Column name (used for name-based heuristics and tls_ref_data
        lookup).
    series:
        Polars :class:`Series` containing the column's data.
    config_type:
        Optional user override (case-insensitive, matched to
        :data:`_CONFIG_TYPE_MAP`).

    Returns
    -------
    DataType
    """
    # 1. Config override (highest priority)
    if config_type is not None:
        mapped = _CONFIG_TYPE_MAP.get(config_type.lower().strip())
        if mapped is not None:
            return mapped

    # 2. tls_ref_data lookup (authoritative type from TlsDB)
    tls_ref = get_tls_ref_dict()
    if name in tls_ref:
        dtype = _tlsdb_type_to_datatype(tls_ref[name]['data_type'])
        if dtype is not None:
            return dtype

    # 3. Value-based sampling
    result = _values_to_type(series, name)

    # 4. Name-based heuristics (only when values returned STRING)
    if result == DataType.STRING:
        name_match = _name_to_type(name)
        if name_match is not None:
            return name_match

    return result


# ---------------------------------------------------------------------------
#  Schema classification (all columns)
# ---------------------------------------------------------------------------


def classify_schema(
    lf: pl.LazyFrame,
    config_overrides: dict[str, str] | None = None,
) -> dict[str, DataType]:
    """Classify all columns in a :class:`LazyFrame`.

    Parameters
    ----------
    lf:
        A Polars :class:`LazyFrame` whose columns to classify.
    config_overrides:
        Optional mapping of ``{col_name: type_string}`` from a
        ``config.yaml`` ``columns:`` section.  These take highest
        priority (see :func:`classify_column`).

    Returns
    -------
    dict[str, DataType]
        Mapping from column name to inferred :class:`DataType`.
    """
    config_overrides = config_overrides or {}
    df_sample = lf.collect(streaming=True)
    result: dict[str, DataType] = {}
    for col_name in df_sample.columns:
        config_type = config_overrides.get(col_name)
        result[col_name] = classify_column(
            name=col_name,
            series=df_sample[col_name],
            config_type=config_type,
        )
    return result
