import json
import logging
from typing import Optional

from semantic_models.schemas import SemanticModelSpec

from semantic_models.exceptions import ExecutionError
from semantic_models.glossary_types import GlossaryTerm
from semantic_models.services.llm_utils import extract_json_block
from semantic_models.utils.spec_utils import (
    get_distinct_values_mode,
    get_sample_limit,
    is_auto_values_enabled,
    should_use_distinct_values,
)

logger = logging.getLogger(__name__)


def _build_mapping_prompt(
    terms: list[GlossaryTerm],
    spec: SemanticModelSpec,
    attribute_samples: dict[str, dict[str, list[str]]] | None = None,
) -> str:
    """Build prompt for LLM to suggest bindings."""
    # Build model description
    entity_blocks = []
    for entity in spec.entities:
        attr_lines = [
            _format_attribute_line(
                a.name,
                a.description or "no description",
                a.type,
                a.column,
                a.pseudoSQLExpression,
                (attribute_samples or {}).get(entity.name, {}).get(a.name),
            )
            for a in entity.attributes
        ]
        metric_lines = [
            f"    - {m.name}: {m.description or 'no description'} (expr: {m.pseudoSQLExpression})"
            for m in entity.metrics
        ]
        filter_lines = [
            f"    - {f.name}: {f.description or 'no description'} (expr: {f.pseudoSQLExpression})"
            for f in entity.filters
        ]

        entity_blocks.append(f"""
Entity: {entity.name}
Description: {entity.description or "N/A"}
Attributes:
{chr(10).join(attr_lines) if attr_lines else "    (none)"}
Metrics:
{chr(10).join(metric_lines) if metric_lines else "    (none)"}
Filters:
{chr(10).join(filter_lines) if filter_lines else "    (none)"}
""")

    model_description = "\n".join(entity_blocks)

    # Build terms list
    terms_json = json.dumps(
        [
            {
                "termId": t.id,
                "term": t.term,
                "description": t.description,
                "synonyms": t.synonyms or [],
            }
            for t in terms
        ],
        indent=2,
    )

    prompt = f"""You are a data analyst mapping business terminology to a semantic data model.

SEMANTIC MODEL:
{model_description}

GLOSSARY TERMS TO MAP:
{terms_json}

TASK:
For each glossary term, suggest the best binding to a semantic model element.
A binding connects a business term to either:
- An entity (targetType="ENTITY_CLASS", targetName=null)
- An attribute (targetType="ATTRIBUTE", targetName=<attribute_name>)
- A metric (targetType="METRIC", targetName=<metric_name>)
- A filter (targetType="FILTER", targetName=<filter_name>)

Return at most one binding per term. Only suggest bindings where you are highly confident about the mapping.
If unsure or the mapping is ambiguous, return no binding for that term. Do not guess.

OUTPUT:
Return ONLY valid JSON matching this schema:

{{
  "bindings": [
    {{
      "termId": "term_id_from_input",
      "targetEntityClass": "entity_id",
      "targetType": "ENTITY_CLASS|ATTRIBUTE|METRIC|FILTER",
      "targetName": "element_id_or_null",
      "reason": "brief explanation"
    }}
  ]
}}

Do NOT include any explanation outside the JSON.
"""
    return prompt


def _format_attribute_line(
    name: str,
    description: str,
    attr_type: object,
    column: str | None,
    expr: str | None,
    samples: list[str] | None,
) -> str:
    details: list[str] = []
    if attr_type:
        attr_type_value = getattr(attr_type, "value", attr_type)
        details.append(f"type: {attr_type_value}")
    if column:
        details.append(f"column: {column}")
    if expr:
        details.append(f"expr: {expr}")
    if samples:
        details.append(f"samples: {samples}")
    details_str = f" ({'; '.join(details)})" if details else ""
    return f"    - {name}: {description}{details_str}"


def _get_mapping_llm_id(explicit_llm_id: Optional[str]) -> str:
    """Pick the LLM for glossary mapping (fallback to default)."""
    from semantic_models.config import get_app_config

    config = get_app_config()
    if explicit_llm_id:
        return explicit_llm_id
    if config.term_extraction_llm_id:
        return config.term_extraction_llm_id
    raise ExecutionError("No LLM configured for glossary mapping")


def _call_llm(llm_id: str, prompt: str) -> str:
    """Call LLM and return raw response text."""
    import dataiku

    client = dataiku.api_client()
    project = client.get_default_project()
    llm = project.get_llm(llm_id)
    completion = llm.new_completion()
    completion.with_message(prompt)
    response = completion.execute()
    return response.text.strip()


def _parse_mapping_response(raw_text: str) -> list[dict]:
    """Parse LLM response JSON into list of binding suggestions."""
    json_text = extract_json_block(raw_text)
    try:
        payload = json.loads(json_text)
    except json.JSONDecodeError as e:
        logger.warning("Failed to parse mapping JSON response: %s", e)
        return []

    bindings = payload.get("bindings") or []
    results: list[dict] = []

    for item in bindings:
        try:
            term_id = item.get("termId")
            entity_id = item.get("targetEntityClass")
            target_type = item.get("targetType")

            if not term_id or not entity_id or not target_type:
                continue

            target_type = target_type.upper()
            if target_type not in ("ENTITY_CLASS", "ATTRIBUTE", "METRIC", "FILTER"):
                continue

            results.append(
                {
                    "termId": term_id,
                    "targetEntityClass": entity_id,
                    "targetType": target_type,
                    "targetName": item.get("targetName"),
                    "reason": item.get("reason"),
                }
            )
        except Exception:
            continue

    return results


def _build_attribute_samples(
    spec: SemanticModelSpec,
    semantic_model_id: str,
    version_id: str,
) -> dict[str, dict[str, list[str]]]:
    samples: dict[str, dict[str, list[str]]] = {}
    for entity in spec.entities:
        for attr in entity.attributes:
            if not should_use_distinct_values(attr):
                continue
            values: list[str] = []
            if attr.manualValues:
                values = list(attr.manualValues)
            elif is_auto_values_enabled(attr):
                limit = get_sample_limit(attr)
                if limit:
                    try:
                        from semantic_models.resolution import SourceKey
                        from semantic_models.services.column_values import fetch_column_values

                        source = SourceKey(
                            semantic_model_id=semantic_model_id,
                            version_id=version_id,
                            entity_name=entity.name,
                        )
                        values = fetch_column_values(source, attr.name, limit)
                    except Exception as e:
                        logger.warning(
                            "Failed to fetch sample values for %s.%s: %s", entity.name, attr.name, e
                        )
            elif get_distinct_values_mode(attr) == "MANUAL":
                logger.info("MANUAL mode with empty sample values for glossary mapping: %s.%s", entity.name, attr.name)
            if values:
                samples.setdefault(entity.name, {})[attr.name] = values
    return samples


def _validate_suggestions(
    suggestions: list[dict],
    spec: "SemanticModelSpec",
    term_ids: set[str],
) -> list[dict]:
    """Filter suggestions to only include valid bindings."""
    entities_by_id = {e.name: e for e in spec.entities}
    valid = []

    for suggestion in suggestions:
        # Check term exists
        if suggestion["termId"] not in term_ids:
            continue

        # Check entity exists
        entity = entities_by_id.get(suggestion["targetEntityClass"])
        if not entity:
            continue

        target_type = suggestion["targetType"]
        target_id = suggestion.get("targetName")

        # Entity-level binding
        if target_type == "ENTITY_CLASS":
            valid.append(suggestion)
            continue

        # Element-level binding - check element exists
        if target_type == "ATTRIBUTE" and target_id:
            if any(a.name == target_id for a in entity.attributes):
                valid.append(suggestion)
        elif target_type == "METRIC" and target_id:
            if any(m.name == target_id for m in entity.metrics):
                valid.append(suggestion)
        elif target_type == "FILTER" and target_id:
            if any(f.name == target_id for f in entity.filters):
                valid.append(suggestion)

    return valid


def suggest_bindings(
    terms: list[GlossaryTerm],
    spec: "SemanticModelSpec",
    llm_id: Optional[str] = None,
    semantic_model_id: Optional[str] = None,
    version_id: Optional[str] = None,
) -> list[dict]:
    """
    Use LLM to suggest bindings for glossary terms.
    """
    if not terms:
        return []

    if not spec.entities:
        return []

    resolved_llm_id = _get_mapping_llm_id(llm_id)
    attribute_samples: dict[str, dict[str, list[str]]] | None = None
    if semantic_model_id and version_id:
        attribute_samples = _build_attribute_samples(
            spec,
            semantic_model_id,
            version_id,
        )
    prompt = _build_mapping_prompt(terms, spec, attribute_samples)

    logger.info(
        "Calling LLM for glossary mapping: %d terms, LLM=%s",
        len(terms),
        resolved_llm_id,
    )
    logger.info("Glossary mapping prompt:\n%s", prompt)

    raw_response = _call_llm(resolved_llm_id, prompt)
    logger.info("Glossary mapping raw response:\n%s", raw_response)
    suggestions = _parse_mapping_response(raw_response)

    term_ids = {t.id for t in terms}
    valid_suggestions = _validate_suggestions(suggestions, spec, term_ids)
    # Keep only one suggestion per term (first in LLM order)
    unique: list[dict] = []
    seen: set[str] = set()
    for suggestion in valid_suggestions:
        term_id = suggestion.get("termId")
        if not term_id or term_id in seen:
            continue
        seen.add(term_id)
        unique.append(suggestion)

    logger.info(
        "Glossary mapping complete: %d suggestions (%d valid)",
        len(suggestions),
        len(unique),
    )

    return unique
