# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Dataset format detection: Alpaca/ShareGPT/ChatML, multimodal/VLM structures, heuristic column mapping."""

import re


def _keyword_in_column(keyword: str, col_name: str) -> bool:
    """Word-boundary keyword match to avoid false positives like 'pic' in 'topic'."""
    return re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE) is not None


CONVERSATION_COLUMNS = ("messages", "conversations", "texts")
_CHATML_KEYS = frozenset({"role", "content"})
_SHAREGPT_KEYS = frozenset({"from", "value"})
_TRACE_SUFFIXES = ("__trace", "_trace")


def _sample_dataset_rows(dataset, limit: int = 100) -> list[dict]:
    try:
        total = min(len(dataset), limit)
        return [dataset[index] for index in range(total)]
    except Exception:
        rows = []
        try:
            for index, row in enumerate(dataset):
                if index >= limit:
                    break
                rows.append(row)
        except Exception:
            return []
        return rows


def _get_dataset_column_names(dataset, sample: dict) -> list[str]:
    column_names = getattr(dataset, "column_names", None)
    if isinstance(column_names, list):
        return [str(column) for column in column_names]
    return [str(column) for column in sample.keys()]


def _is_trace_conversation_name(column_name: str) -> bool:
    return column_name.lower().endswith(_TRACE_SUFFIXES)


def _inspect_conversation_column(rows: list[dict], column_name: str) -> dict | None:
    turn_keys: set[str] = set()
    has_chatml = False
    has_sharegpt = False

    for row in rows:
        if not isinstance(row, dict) or column_name not in row:
            continue
        chat_data = row[column_name]
        if not isinstance(chat_data, list) or len(chat_data) == 0:
            continue
        for turn in chat_data:
            if not isinstance(turn, dict):
                continue
            keys = {str(key) for key in turn.keys()}
            turn_keys.update(keys)
            if _SHAREGPT_KEYS.issubset(keys):
                has_sharegpt = True
            if _CHATML_KEYS.issubset(keys):
                has_chatml = True

    if has_sharegpt:
        return {
            "format": "sharegpt",
            "chat_column": column_name,
            "needs_standardization": True,
            "sample_keys": sorted(turn_keys),
        }
    if has_chatml:
        return {
            "format": "chatml",
            "chat_column": column_name,
            "needs_standardization": False,
            "sample_keys": sorted(turn_keys),
        }
    if turn_keys:
        return {
            "format": "unknown",
            "chat_column": column_name,
            "needs_standardization": None,
            "sample_keys": sorted(turn_keys),
        }
    return None


def _detect_conversation_column(rows: list[dict], column_names: list[str]) -> dict | None:
    column_name_set = set(column_names)
    unknown_exact = None
    for column_name in CONVERSATION_COLUMNS:
        if column_name not in column_name_set:
            continue
        inspected = _inspect_conversation_column(rows, column_name)
        if inspected and inspected["format"] in {"sharegpt", "chatml"}:
            return inspected
        if inspected and unknown_exact is None:
            unknown_exact = inspected

    structural_candidates = []
    for column_name in column_names:
        if column_name in CONVERSATION_COLUMNS:
            continue
        inspected = _inspect_conversation_column(rows, column_name)
        if inspected and inspected["format"] in {"sharegpt", "chatml"}:
            structural_candidates.append(inspected)

    trace_candidates = [
        candidate
        for candidate in structural_candidates
        if _is_trace_conversation_name(candidate["chat_column"])
    ]
    if len(trace_candidates) == 1:
        return trace_candidates[0]
    if len(trace_candidates) > 1:
        return unknown_exact
    if len(structural_candidates) == 1:
        return structural_candidates[0]
    if unknown_exact is not None:
        return unknown_exact
    return None


def detect_dataset_format(dataset):
    """Detect dataset format by inspecting structure.

    Returns:
        dict: {
            "format": "alpaca" | "sharegpt" | "chatml" | "unknown",
            "chat_column": str | None,
            "needs_standardization": bool,
            "sample_keys": list of keys found in messages (for debugging)
        }
    """
    sample_rows = _sample_dataset_rows(dataset)
    if not sample_rows:
        return {
            "format": "unknown",
            "chat_column": None,
            "needs_standardization": None,
            "sample_keys": [],
        }

    column_names = _get_dataset_column_names(dataset, sample_rows[0])
    column_name_set = set(column_names)

    # Alpaca
    alpaca_columns = {"instruction", "output"}
    if alpaca_columns.issubset(column_name_set):
        return {
            "format": "alpaca",
            "chat_column": None,
            "needs_standardization": False,
            "sample_keys": [],
        }

    conversation = _detect_conversation_column(sample_rows, column_names)
    if conversation:
        return conversation

    return {
        "format": "unknown",
        "chat_column": None,
        "needs_standardization": None,
        "sample_keys": [],
    }


def detect_custom_format_heuristic(dataset):
    """Detection with priority scoring.

    Strategy for ambiguous keywords like 'task':
    1. Detect assistant first (unambiguous)
    2. Detect user using high-priority keywords first
    3. Check REMAINING columns for system keywords (including 'task')
    4. Only if no system match, use 'task' as fallback user
    """
    sample = next(iter(dataset))
    all_columns = list(sample.keys())

    mapping = {}

    assistant_words = [
        "output",
        "answer",
        "response",
        "assistant",
        "completion",
        "expected",
        "recommendation",
        "reply",
        "result",
        "target",
        "solution",
        "explanation",
        "solve",
    ]

    user_words_high_priority = [
        "input",
        "question",
        "query",
        "prompt",
        "instruction",
        "request",
        "snippet",
        "user",
        "text",
        "problem",
        "exercise",
    ]
    user_words_low_priority = ["task"]  # Ambiguous - can be user OR system
    user_words = user_words_high_priority + user_words_low_priority

    system_words = [
        "system",
        "context",
        "description",
        "persona",
        "role",
        "template",
        "task",  # also a system keyword
    ]

    # Metadata columns to ignore.
    metadata_exact_match = {
        "id",
        "idx",
        "index",
        "key",
        "timestamp",
        "date",
        "metadata",
        "source",
        "kind",
        "type",
        "category",
        "score",
        "label",
        "tag",
        "inference_mode",
    }

    metadata_prefix_patterns = [
        "problem_type",
        "problem_source",
        "generation_model",
        "pass_rate",
    ]

    priority_patterns = {
        "generated": 100,
        "gen_": 90,
        "model_": 80,
        "predicted": 70,
        "completion": 60,
    }

    def has_keyword(col_name, keywords):
        """True if any keyword appears in the column name."""
        col_lower = col_name.lower()
        col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "")

        for keyword in keywords:
            if keyword in col_lower or keyword in col_normalized:
                return True
        return False

    def is_metadata(col_name):
        """True if the column is likely metadata."""
        col_lower = col_name.lower()

        if col_lower in metadata_exact_match:
            return True

        if col_lower in metadata_prefix_patterns:
            return True

        for pattern in metadata_prefix_patterns:
            if col_lower.startswith(pattern.split("_")[0] + "_") and col_lower != pattern:
                if "_" in col_lower:
                    prefix = col_lower.split("_")[0]
                    if prefix in ["generation", "pass", "inference"]:
                        return True

        if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]:
            return True

        return False

    def get_priority_score(col_name):
        """Priority score from column-name patterns."""
        col_lower = col_name.lower()
        score = 0

        for pattern, pattern_score in priority_patterns.items():
            if pattern in col_lower:
                score += pattern_score

        return score

    def get_content_length(col_name):
        """Average content length for this column."""
        try:
            if col_name in sample and sample[col_name]:
                content = str(sample[col_name])
                return len(content)
            return 0
        except:
            return 0

    def score_column(col_name, keywords, role_type, num_candidates):
        """Score how likely a column is to be a given role."""
        if not has_keyword(col_name, keywords):
            return 0

        score = 0
        score += 10

        # Penalize ambiguous "task" so other user columns win.
        if role_type == "user":
            col_lower = col_name.lower()
            if "task" in col_lower and not any(kw in col_lower for kw in user_words_high_priority):
                score -= 15

        priority_bonus = get_priority_score(col_name)
        score += priority_bonus

        if role_type in ["assistant", "user"]:
            avg_length = get_content_length(col_name)

            if num_candidates > 1:
                if avg_length > 1000:
                    score += 50
                elif avg_length > 200:
                    score += 30
                elif avg_length > 50:
                    score += 10
                elif avg_length < 50:
                    score -= 20
            else:
                if avg_length > 1000:
                    score += 50
                elif avg_length > 200:
                    score += 30
                elif avg_length > 50:
                    score += 10

        return score

    content_columns = [col for col in all_columns if not is_metadata(col)]

    assistant_potential = [col for col in content_columns if has_keyword(col, assistant_words)]
    user_potential = [col for col in content_columns if has_keyword(col, user_words)]

    # STEP 1: best ASSISTANT column
    assistant_candidates = []
    for col in assistant_potential:
        score = score_column(col, assistant_words, "assistant", len(assistant_potential))
        if score > 0:
            assistant_candidates.append((col, score))

    if assistant_candidates:
        assistant_candidates.sort(key = lambda x: x[1], reverse = True)
        assistant_col = assistant_candidates[0][0]
        mapping[assistant_col] = "assistant"
    else:
        assistant_col = None

    # STEP 2: best USER column (penalizing ambiguous keywords)
    user_candidates = []
    for col in user_potential:
        if col == assistant_col:
            continue
        score = score_column(col, user_words, "user", len(user_potential))
        if score > 0:
            user_candidates.append((col, score))

    if user_candidates:
        user_candidates.sort(key = lambda x: x[1], reverse = True)
        user_col = user_candidates[0][0]
        mapping[user_col] = "user"
    else:
        user_col = None

    # STEP 3: check remaining columns for SYSTEM matches
    remaining_columns = [col for col in content_columns if col not in mapping]

    system_col = None
    for col in remaining_columns:
        if has_keyword(col, system_words):
            mapping[col] = "system"
            system_col = col
            break

    # STEP 4: handle any additional remaining columns
    if system_col:
        remaining_columns = [col for col in remaining_columns if col != system_col]

    if len(remaining_columns) >= 1:
        remaining_col = remaining_columns[0]

        # No strong keyword match: decide by what's missing.
        if not has_keyword(remaining_col, user_words + assistant_words):
            mapping[remaining_col] = "system"
        elif user_col is None:
            mapping[remaining_col] = "user"
        else:
            mapping[remaining_col] = "system"

    # Ensure at least user + assistant.
    has_user = any(role == "user" for role in mapping.values())
    has_assistant = any(role == "assistant" for role in mapping.values())

    if not has_user and len(remaining_columns) > 0:
        for col in remaining_columns:
            if col not in mapping:
                mapping[col] = "user"
                has_user = True
                break

    if has_user and has_assistant:
        return mapping

    return None


def detect_multimodal_dataset(dataset):
    """Detect multimodal data (images and/or audio) in a dataset.

    Two passes per modality: column-name keyword heuristic, then value-type
    inspection. Returns a dict with is_image/is_audio flags, detected columns,
    modality types, and detected audio/text/speaker columns.
    """
    sample = next(iter(dataset))
    column_names = list(sample.keys())

    image_keywords = [
        "image",
        "img",
        "pixel",
        "jpg",
        "jpeg",
        "png",
        "webp",
        "bmp",
        "gif",
        "tiff",
        "svg",
        "photo",
        "pic",
        "picture",
        "visual",
        "file_name",
        "filename",
    ]

    audio_keywords = ["audio", "speech", "wav", "waveform", "sound"]

    multimodal_columns = []
    audio_columns = []
    modality_types = set()

    # ── Image detection ─────────────────────────────────────
    # Pass 1: column-name heuristic (word-boundary match)
    for col_name in column_names:
        for keyword in image_keywords:
            if _keyword_in_column(keyword, col_name):
                multimodal_columns.append(col_name)
                modality_types.add(keyword)
                break

    # Pass 2: inspect actual values
    already_detected = set(multimodal_columns)
    for col_name in column_names:
        if col_name in already_detected:
            continue
        value = sample[col_name]
        if _is_image_value(value):
            multimodal_columns.append(col_name)
            modality_types.add("image")

    # ── Audio detection ─────────────────────────────────────
    # Pass 1: column-name heuristic (word-boundary match)
    for col_name in column_names:
        for keyword in audio_keywords:
            if _keyword_in_column(keyword, col_name):
                audio_columns.append(col_name)
                modality_types.add("audio")
                break

    # Pass 2: inspect actual values (catches non-obvious column names)
    already_audio = set(audio_columns)
    for col_name in column_names:
        if col_name in already_audio:
            continue
        value = sample[col_name]
        if _is_audio_value(value):
            audio_columns.append(col_name)
            modality_types.add("audio")

    # Drop audio columns from the image list (a {"bytes","path"} audio column
    # can match _is_image_value).
    if audio_columns:
        audio_set = set(audio_columns)
        multimodal_columns = [c for c in multimodal_columns if c not in audio_set]

    # Text column for audio datasets.
    detected_text_col = None
    if audio_columns:
        text_keywords = ["text", "sentence", "transcript", "transcription", "label"]
        for col_name in column_names:
            if col_name.lower() in text_keywords:
                detected_text_col = col_name
                break

    is_audio = len(audio_columns) > 0

    # speaker_id column for TTS datasets (CSM, Orpheus, Spark)
    detected_speaker_col = None
    if audio_columns:
        speaker_keywords = ["source", "speaker", "speaker_id"]
        for col_name in column_names:
            if col_name.lower() in speaker_keywords:
                detected_speaker_col = col_name
                break

    return {
        "is_image": len(multimodal_columns) > 0,
        "multimodal_columns": multimodal_columns,
        "modality_types": list(modality_types),
        "is_audio": is_audio,
        "audio_columns": audio_columns,
        "detected_audio_column": audio_columns[0] if audio_columns else None,
        "detected_text_column": detected_text_col,
        "detected_speaker_column": detected_speaker_col,
    }


def _is_image_value(value) -> bool:
    """Check if a single sample value looks like image data."""
    if value is None:
        return False

    try:
        from PIL.Image import Image as PILImage
        if isinstance(value, PILImage):
            return True
    except ImportError:
        pass

    # HF Image feature: decoded as PIL, or {"bytes", "path"} when undecoded.
    # Exclude audio dicts (decoded audio has "array" + "sampling_rate").
    if isinstance(value, dict):
        if "array" in value and "sampling_rate" in value:
            return False  # audio, not image
        if "bytes" in value and "path" in value:
            # Use path extension to exclude audio files.
            path = value.get("path") or ""
            if isinstance(path, str) and any(
                path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
            ):
                return False
            return True

    if isinstance(value, (bytes, bytearray)):
        return _has_image_header(value)

    # String that looks like an image file path or URL.
    _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg")
    if isinstance(value, str) and len(value) < 1000:
        lower = value.strip().lower()
        if lower.startswith(("http://", "https://")) and any(
            lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS
        ):
            return True
        if any(lower.endswith(ext) for ext in _IMAGE_EXTS):
            return True

    return False


_AUDIO_EXTENSIONS = (
    ".wav",
    ".mp3",
    ".flac",
    ".ogg",
    ".opus",
    ".m4a",
    ".aac",
    ".wma",
    ".webm",
)


def _is_audio_value(value) -> bool:
    """Check if a single sample value looks like audio data."""
    if value is None:
        return False

    # HF Audio feature: decoded -> {"array", "sampling_rate"}; undecoded -> {"bytes", "path"}.
    if isinstance(value, dict):
        if "array" in value and "sampling_rate" in value:
            return True
        if "bytes" in value or "path" in value:
            path = value.get("path") or ""
            if isinstance(path, str) and any(
                path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
            ):
                return True

    return False


def _has_image_header(data: bytes) -> bool:
    """Quick magic-byte check for common image formats."""
    if len(data) < 4:
        return False
    if data[:2] == b"\xff\xd8":  # JPEG
        return True
    if data[:4] == b"\x89PNG":  # PNG
        return True
    if data[:3] == b"GIF":  # GIF
        return True
    if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP":  # WebP
        return True
    if data[:2] == b"BM":  # BMP
        return True
    return False


def detect_vlm_dataset_structure(dataset):
    """Detect which VLM dataset shape this is:
    - Standard VLM messages (image objects in content)
    - Llava format (image indices + separate images column)
    - Simple format needing conversion (image + text columns)
    """
    try:
        sample = next(iter(dataset))
    except StopIteration:
        return {
            "format": "unknown",
            "needs_conversion": None,
            "image_column": None,
            "text_column": None,
            "messages_column": None,
        }

    column_names = set(sample.keys())

    if "messages" in column_names:
        messages = sample["messages"]

        if messages and len(messages) > 0:
            first_msg = messages[0]
            if "content" in first_msg:
                content = first_msg["content"]

                if isinstance(content, list) and len(content) > 0:
                    if isinstance(content[0], dict) and "type" in content[0]:
                        # Llava format?
                        has_index = any(
                            "index" in item for item in content if isinstance(item, dict)
                        )
                        has_images_column = "images" in column_names

                        if has_index and has_images_column:
                            return {
                                "format": "vlm_messages_llava",
                                "needs_conversion": True,
                                "messages_column": "messages",
                                "image_column": "images",
                                "text_column": None,
                            }

                        # Standard VLM format
                        has_image = any(
                            "image" in item for item in content if isinstance(item, dict)
                        )
                        if has_image:
                            return {
                                "format": "vlm_messages",
                                "needs_conversion": False,
                                "messages_column": "messages",
                                "image_column": None,
                                "text_column": None,
                            }

    # ShareGPT/ChatML conversations with <image> placeholder + companion
    # image column (e.g. Lin-Chen/ShareGPT4V, LLaVA-style datasets)
    for chat_col in ("conversations", "messages"):
        if chat_col not in column_names:
            continue
        chat_data = sample[chat_col]
        if not isinstance(chat_data, list) or len(chat_data) == 0:
            continue
        first_msg = chat_data[0]
        if not isinstance(first_msg, dict):
            continue
        # ShareGPT (from/value) or ChatML (role/content).
        msg_text = first_msg.get("value") or first_msg.get("content")
        if not isinstance(msg_text, str):
            continue
        has_image_placeholder = any(
            "<image>" in str(m.get("value", "") or m.get("content", ""))
            for m in chat_data
            if isinstance(m, dict)
        )
        if not has_image_placeholder:
            continue
        # Find companion image column.
        image_col = None
        for col in column_names:
            if col == chat_col:
                continue
            if _keyword_in_column("image", col) or _keyword_in_column("img", col):
                image_col = col
                break
        if image_col:
            return {
                "format": "sharegpt_with_images",
                "needs_conversion": True,
                "image_column": image_col,
                "text_column": None,
                "messages_column": chat_col,
            }

    # Find image and text columns, filtering out metadata patterns
    metadata_patterns = {
        "suffixes": [
            "_id",
            "_url",
            "_name",
            "_filename",
            "_uri",
            "_link",
            "_key",
            "_index",
        ],
        "prefixes": [
            "id_",
            "url_",
            "name_",
            "filename_",
            "uri_",
            "link_",
            "key_",
            "index_",
        ],
    }

    image_keywords = [
        "image",
        "img",
        "photo",
        "picture",
        "pic",
        "visual",
        "scan",
        "file_name",
        "filename",
    ]

    text_keywords = [
        "text",
        "caption",
        "captions",
        "description",
        "answer",
        "output",
        "response",
        "label",
    ]

    def is_metadata_column(col_name):
        """True if the column name looks like metadata."""
        col_lower = col_name.lower()

        if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]):
            return True
        if any(col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"]):
            return True

        return False

    def _score_image_candidate(col, sample_value):
        """Score a candidate image column by how resolvable its value is."""
        # PIL Image (already loaded) -> highest.
        if hasattr(sample_value, "size") and hasattr(sample_value, "mode"):
            return 100

        # HF Image feature dict.
        if isinstance(sample_value, dict) and ("bytes" in sample_value or "path" in sample_value):
            return 75

        if isinstance(sample_value, str):
            if sample_value.startswith(("http://", "https://")):  # URL
                return 70 if not is_metadata_column(col) else 55
            if is_metadata_column(col):  # bare file path
                return 30
            return 50

        return 0

    def _probe_image_candidate(col, sample_value):
        """Probe whether an image candidate is reachable (True unless definitely broken)."""
        import os

        # PIL / dict — already loaded.
        if not isinstance(sample_value, str):
            return True

        # Local file — check it exists.
        if not sample_value.startswith(("http://", "https://")):
            return os.path.exists(sample_value)  # bare filenames return False, that's OK

        # URL — quick HEAD with short timeout.
        try:
            import urllib.request

            req = urllib.request.Request(sample_value, method = "HEAD")
            resp = urllib.request.urlopen(req, timeout = 3)
            return resp.status < 400
        except Exception:
            return False

    def find_image_column():
        """Find image column by keyword match + value-based fallback, probing for one that works."""
        candidates = []

        # Pass 1: keyword-matched columns.
        for col in column_names:
            if any(_keyword_in_column(keyword, col) for keyword in image_keywords):
                sample_value = sample[col]
                score = _score_image_candidate(col, sample_value)
                if score > 0:
                    candidates.append((col, score))

        # Pass 2: value-based fallback for image URLs/paths even when the name
        # doesn't match keywords.
        already = {c[0] for c in candidates}
        for col in column_names:
            if col in already:
                continue
            sample_value = sample[col]
            if _is_image_value(sample_value):
                score = _score_image_candidate(col, sample_value)
                # Penalise non-keyword columns so keyword matches win on ties.
                candidates.append((col, max(score - 5, 1)))

        if not candidates:
            return None

        candidates.sort(key = lambda x: x[1], reverse = True)

        # Single candidate or top is PIL/dict — no probing needed.
        if len(candidates) == 1 or candidates[0][1] >= 75:
            return candidates[0][0]

        # Multiple string candidates — probe for one that works.
        for col, score in candidates:
            sample_value = sample[col]
            if _probe_image_candidate(col, sample_value):
                return col

        # None probed OK — return highest-scored; conversion may still resolve it.
        return candidates[0][0]

    def find_text_column():
        """Find text column: skip metadata, match keywords."""
        candidates = []

        for col in column_names:
            if is_metadata_column(col):
                continue

            if any(_keyword_in_column(keyword, col) for keyword in text_keywords):
                sample_value = sample[col]

                if isinstance(sample_value, str) and len(sample_value) > 0:
                    # Longer text = higher priority (content, not a label).
                    priority = min(len(sample_value), 1000)
                    candidates.append((col, priority))
                elif (
                    isinstance(sample_value, list)
                    and len(sample_value) > 0
                    and isinstance(sample_value[0], str)
                ):
                    # List of strings (e.g. captions) — lower priority than plain str.
                    priority = min(len(sample_value[0]), 1000) // 2
                    candidates.append((col, priority))

        if candidates:
            candidates.sort(key = lambda x: x[1], reverse = True)
            return candidates[0][0]

        return None

    found_image = find_image_column()
    found_text = find_text_column()

    if found_image and found_text:
        return {
            "format": "simple_image_text",
            "needs_conversion": True,
            "image_column": found_image,
            "text_column": found_text,
            "messages_column": None,
        }

    return {
        "format": "unknown",
        "needs_conversion": None,
        "image_column": found_image,
        "text_column": found_text,
        "messages_column": None,
    }
