# src/fmatch/core/utils.py

import re
import pandas as pd
from typing import Dict, Any, Optional, Tuple
import logging
from pathlib import Path
import csv
from datetime import datetime

# Try to import PSL library
try:
    from publicsuffix2 import PublicSuffixList

    psl = PublicSuffixList()
except ImportError:
    logging.warning(
        "publicsuffix2 not installed. Domain normalization will be limited."
    )
    psl = None

# Constants for enhanced sampling
DEFAULT_SAMPLE_SIZE = 1000  # Increased from 500
MAX_SAMPLE_SIZE = 4000
TARGET_STANDARD_ERROR = 0.02
MIN_SAMPLE_FOR_ANALYSIS = 200


def sanitize_column_name(name: str) -> str:
    """Single source of truth for column name sanitization.

    Converts a column name to a safe string for use in filenames or queries.

    Args:
        name: The column name to sanitize

    Returns:
        A sanitized version of the name suitable for use as an identifier
    """
    name_str = str(name).lower()
    # Replace non-alphanumeric with underscore
    name_str = re.sub(r"[^a-z0-9]+", "_", name_str)
    # Collapse multiple underscores and strip
    name_str = re.sub(r"_+", "_", name_str).strip("_")
    return name_str or "unnamed"


def normalize_domain(domain: str) -> str:
    """
    Normalize domain using Public Suffix List for accurate matching.

    Examples:
        www.example.com -> example.com
        blog.example.co.uk -> example.co.uk (not co.uk!)
        https://example.com/path -> example.com
    """
    if not isinstance(domain, str) or not domain.strip():
        return ""

    # Basic cleaning
    domain = domain.strip().lower()
    domain = re.sub(r"^https?://", "", domain)  # Remove protocol
    domain = re.sub(r"^www\.", "", domain)  # Remove www
    domain = domain.split("/")[0]  # Remove path
    domain = domain.split(":")[0]  # Remove port

    if not domain:
        return ""

    # Use PSL for proper domain extraction if available
    if psl:
        try:
            return psl.get_public_suffix(domain) or domain
        except Exception as e:
            logging.debug(f"Domain normalization error for '{domain}': {e}")
            return domain
    else:
        # Fallback: just return cleaned domain
        return domain


def analyze_domain_matching_strategy(
    src_series: pd.Series,
    ref_series: pd.Series,
    sample_size: int = None,
    min_valid_samples: int = 50,
    variant_threshold: float = 0.15,
    overlap_threshold: float = 0.60,
    use_adaptive: bool = True,
) -> Dict[str, Any]:
    """
    Intelligently choose domain matching strategy based on data characteristics.
    Now with adaptive sampling and early accept optimization.

    Returns dict with:
        - algorithm: 'Exact' or 'JaroWinkler'
        - confidence: 0.0-1.0
        - weight: 0.1-1.0 (updated to handle extreme low overlap)
        - preprocessing: 'normalize_domain' if applicable
        - reason: Human-readable explanation
        - metrics: Raw measurements for transparency
    """
    # Import sampling components
    from fmatch.core.sampling import (
        AdaptiveSampler,
        EarlyAcceptChecker,
        stratified_domain_sample,
    )

    # Use default sample size if not specified
    if sample_size is None:
        sample_size = DEFAULT_SAMPLE_SIZE

    # Validate input
    src_valid = src_series.dropna()
    ref_valid = ref_series.dropna()

    if len(src_valid) < min_valid_samples or len(ref_valid) < min_valid_samples:
        return {
            "algorithm": "JaroWinkler",
            "confidence": 0.5,
            "weight": 0.5,
            "reason": f"Insufficient valid data (src={len(src_valid)}, ref={len(ref_valid)})",
            "metrics": {
                "src_valid_count": len(src_valid),
                "ref_valid_count": len(ref_valid),
                "src_variant_rate": 0.0,
                "ref_variant_rate": 0.0,
                "overlap_ratio": 0.0,
                "sampling_info": {"method": "insufficient_data"},
            },
        }

    # Quick initial sample for early accept check
    initial_sample_size = MIN_SAMPLE_FOR_ANALYSIS
    src_initial = src_valid.sample(
        min(initial_sample_size, len(src_valid)), random_state=42
    )
    ref_initial = ref_valid.sample(
        min(initial_sample_size, len(ref_valid)), random_state=42
    )

    # Calculate initial metrics
    src_norm_initial = src_initial.map(normalize_domain)
    ref_norm_initial = ref_initial.map(normalize_domain)

    initial_metrics = {
        "overlap_ratio": _calculate_overlap(src_norm_initial, ref_norm_initial),
        "variant_rate": max(
            (src_initial != src_norm_initial).mean(),
            (ref_initial != ref_norm_initial).mean(),
        ),
        "null_rate": max(src_series.isna().mean(), ref_series.isna().mean()),
    }

    # Check for early accept
    should_accept, reason = EarlyAcceptChecker.should_early_accept(
        initial_metrics, initial_sample_size
    )

    if should_accept:
        # Return decision based on initial metrics
        if reason == "very_clean_data":
            return {
                "algorithm": "Exact",
                "confidence": 0.95,
                "weight": 1.0,
                "preprocessing": "normalize_domain",
                "reason": "Very clean data detected - using exact matching",
                "metrics": {
                    **initial_metrics,
                    "sampling_info": {"method": "early_accept", "reason": reason},
                },
            }
        elif reason == "extremely_low_overlap":
            # Don't penalize so harshly - domains are still useful
            weight = max(0.5, 0.7)  # At least 0.5 weight
            return {
                "algorithm": "JaroWinkler",
                "confidence": 0.4,  # Moderate confidence
                "weight": weight,
                "reason": f"Domain column: Low overlap ({initial_metrics['overlap_ratio']:.1%}) but keeping weight at {weight}",
                "metrics": {
                    **initial_metrics,
                    "extreme_low_overlap": True,
                    "sampling_info": {"method": "early_accept", "reason": reason},
                },
            }

    # Use adaptive sampling if enabled
    if use_adaptive:
        sampler = AdaptiveSampler()

        # Define metric function for overlap
        def overlap_metric(samples):
            split_point = len(samples) // 2
            src_normalized = [normalize_domain(s) for s in samples[:split_point]]
            ref_normalized = [normalize_domain(s) for s in samples[split_point:]]

            src_unique = set(s for s in src_normalized if s)
            ref_unique = set(s for s in ref_normalized if s)

            if not src_unique or not ref_unique:
                return 0.0

            common = src_unique & ref_unique
            return len(common) / min(len(src_unique), len(ref_unique))

        # Combine series for sampling
        combined = pd.concat([src_valid, ref_valid])
        samples, overlap_ratio, se, info = sampler.sample_until_stable(
            combined, overlap_metric
        )

        # Split samples back
        split_point = len(samples) // 2
        src_sample = pd.Series(samples[:split_point])
        ref_sample = pd.Series(samples[split_point:])

        sampling_info = {
            "method": "adaptive",
            "converged": info["converged"],
            "samples_used": info["samples_used"],
            "standard_error": se,
        }
    else:
        # Use stratified sampling for domains
        src_sample = stratified_domain_sample(src_valid, sample_size)
        ref_sample = stratified_domain_sample(ref_valid, sample_size)
        sampling_info = {"method": "stratified", "sample_size": sample_size}

    # Normalize domains
    src_normalized = src_sample.map(normalize_domain)
    ref_normalized = ref_sample.map(normalize_domain)

    # Calculate variant rates
    src_variant_rate = src_sample.ne(src_normalized).mean()
    ref_variant_rate = ref_sample.ne(ref_normalized).mean()

    # Calculate overlap
    src_unique = set(src_normalized[src_normalized != ""])
    ref_unique = set(ref_normalized[ref_normalized != ""])

    if not src_unique or not ref_unique:
        return {
            "algorithm": "JaroWinkler",
            "confidence": 0.4,
            "weight": 0.3,
            "reason": "Empty domain sets after normalization",
            "metrics": {
                "src_variant_rate": float(src_variant_rate),
                "ref_variant_rate": float(ref_variant_rate),
                "overlap_ratio": 0.0,
                "sampling_info": sampling_info,
            },
        }

    common_domains = src_unique & ref_unique
    overlap_ratio = len(common_domains) / min(len(src_unique), len(ref_unique))

    # Enhanced decision logic with extreme low overlap handling
    if overlap_ratio < 0.01:  # Less than 1% overlap
        # Don't penalize so harshly - domains are still useful
        weight = max(0.5, 0.7)  # At least 0.5 weight
        return {
            "algorithm": "JaroWinkler",
            "confidence": 0.4,  # Moderate confidence
            "weight": weight,
            "reason": f"Low overlap ({overlap_ratio:.1%}) but keeping weight at {weight} - domains still useful",
            "preprocessing": "normalize_domain",
            "metrics": {
                "src_variant_rate": float(src_variant_rate),
                "ref_variant_rate": float(ref_variant_rate),
                "overlap_ratio": float(overlap_ratio),
                "common_domains": len(common_domains),
                "src_unique": len(src_unique),
                "ref_unique": len(ref_unique),
                "extreme_low_overlap": True,
                "sampling_info": sampling_info,
            },
        }
    elif (
        src_variant_rate < variant_threshold
        and ref_variant_rate < variant_threshold
        and overlap_ratio > overlap_threshold
    ):
        # Clean data with good overlap -> use Exact
        confidence = min(0.9 + overlap_ratio * 0.1, 1.0)

        return {
            "algorithm": "Exact",
            "confidence": round(confidence, 2),
            "weight": 1.0,
            "preprocessing": "normalize_domain",
            "reason": f"Clean domains (variants: src={src_variant_rate:.1%}, ref={ref_variant_rate:.1%}) with {overlap_ratio:.1%} overlap",
            "metrics": {
                "src_variant_rate": float(src_variant_rate),
                "ref_variant_rate": float(ref_variant_rate),
                "overlap_ratio": float(overlap_ratio),
                "common_domains": len(common_domains),
                "src_unique": len(src_unique),
                "ref_unique": len(ref_unique),
                "sampling_info": sampling_info,
            },
        }
    else:
        # Messy data or poor overlap -> use fuzzy
        # Weight formula WITHOUT the problematic max(0.5, ...) clamp
        raw_weight = 0.25 + 0.75 * overlap_ratio
        weight = min(0.9, raw_weight)  # Only apply ceiling
        confidence = 0.7 + overlap_ratio * 0.3

        reasons = []
        if max(src_variant_rate, ref_variant_rate) >= variant_threshold:
            reasons.append(
                f"high variants ({max(src_variant_rate, ref_variant_rate):.1%})"
            )
        if overlap_ratio <= overlap_threshold:
            reasons.append(f"low overlap ({overlap_ratio:.1%})")

        return {
            "algorithm": "JaroWinkler",
            "confidence": round(confidence, 2),
            "weight": round(weight, 2),
            "reason": f"Domain issues: {', '.join(reasons)}",
            "metrics": {
                "src_variant_rate": float(src_variant_rate),
                "ref_variant_rate": float(ref_variant_rate),
                "overlap_ratio": float(overlap_ratio),
                "common_domains": len(common_domains),
                "src_unique": len(src_unique),
                "ref_unique": len(ref_unique),
                "raw_weight": round(raw_weight, 3),
                "weight_reason": "fuzzy_match",
                "sampling_info": sampling_info,
            },
        }


def _calculate_overlap(src_normalized: pd.Series, ref_normalized: pd.Series) -> float:
    """Helper to calculate overlap ratio between two normalized series."""
    src_unique = set(src_normalized[src_normalized != ""])
    ref_unique = set(ref_normalized[ref_normalized != ""])

    if not src_unique or not ref_unique:
        return 0.0

    common = src_unique & ref_unique
    return len(common) / min(len(src_unique), len(ref_unique))


def calculate_domain_weight(
    overlap_ratio: float, variant_rate: float, extreme_low_threshold: float = 0.01
) -> Tuple[float, str]:
    """Calculate weight with proper handling of extreme cases."""

    # Extreme low overlap - minimal contribution
    if overlap_ratio < extreme_low_threshold:
        return 0.1, "extreme_low_overlap"

    # Clean data with good overlap
    if overlap_ratio > 0.6 and variant_rate < 0.15:
        return 1.0, "high_quality_match"

    # Normal fuzzy matching weight
    # NO max(0.5, ...) clamp as per feedback
    raw_weight = 0.25 + 0.75 * overlap_ratio
    weight = min(0.9, raw_weight)  # Only apply ceiling

    return round(weight, 2), "fuzzy_match"


def log_domain_analysis_metrics(
    strategy_result: Dict[str, Any],
    source_col: str,
    ref_col: str,
    output_dir: Optional[Path] = None,
) -> None:
    """Enhanced logging with sampling information."""
    if output_dir is None:
        output_dir = Path.home() / ".fmatch" / "metrics"
    output_dir.mkdir(parents=True, exist_ok=True)

    csv_path = output_dir / "domain_analysis_log.csv"

    # Enhanced row data with sampling info
    row = {
        "timestamp": datetime.now().isoformat(),
        "source_col": source_col,
        "ref_col": ref_col,
        "algorithm": strategy_result["algorithm"],
        "weight": strategy_result.get("weight", 1.0),
        "weight_reason": strategy_result["metrics"].get("weight_reason", "unknown"),
        "confidence": strategy_result["confidence"],
        "overlap_ratio": strategy_result["metrics"]["overlap_ratio"],
        "src_variant_rate": strategy_result["metrics"]["src_variant_rate"],
        "ref_variant_rate": strategy_result["metrics"]["ref_variant_rate"],
        "sampling_method": strategy_result["metrics"]
        .get("sampling_info", {})
        .get("method", "unknown"),
        "samples_used": strategy_result["metrics"]
        .get("sampling_info", {})
        .get("samples_used", 0),
        "standard_error": strategy_result["metrics"]
        .get("sampling_info", {})
        .get("standard_error", None),
        "converged": strategy_result["metrics"]
        .get("sampling_info", {})
        .get("converged", None),
        "reason": strategy_result["reason"],
    }

    # Write with proper encoding
    file_exists = csv_path.exists()
    try:
        with open(csv_path, "a", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=row.keys())
            if not file_exists:
                writer.writeheader()
            writer.writerow(row)
    except Exception as e:
        logging.debug(f"Could not log domain metrics: {e}")
