import json
import logging
import os
from typing import Optional

from semantic_models.schemas import SemanticModelSpec
from semantic_models.exceptions import ExecutionError, ValidationError
from semantic_models.services.document_extraction import DocumentSection, extract_document_sections
from semantic_models.services.glossary_utils import normalize_term
from semantic_models.services.llm_utils import extract_json_block

logger = logging.getLogger(__name__)

MAX_TEXT_CHARS = 8000  # Max chars per section for LLM prompt

ALLOWED_EXTENSIONS = {".txt", ".md", ".pdf", ".doc", ".docx", ".rtf", ".csv", ".json", ".pptx"}


def validate_document_extension(filename: str) -> None:
    _, ext = os.path.splitext(filename or "")
    ext = ext.lower()
    if not ext or ext not in ALLOWED_EXTENSIONS:
        allowed_list = ", ".join(sorted(ALLOWED_EXTENSIONS))
        raise ValidationError(f"File type '{ext or '(none)'}' is not allowed. Supported formats: {allowed_list}")


def _build_glossary_extraction_prompt(
    text: str,
    spec: SemanticModelSpec,
    section_label: str = "",
    max_chars: int = MAX_TEXT_CHARS,
) -> str:
    entity_blocks: list[str] = []

    for entity in spec.entities:
        attr_names = [a.name for a in entity.attributes]
        metric_names = [m.name for m in entity.metrics]
        filter_names = [f.name for f in entity.filters]

        block_lines = [
            f"Entity {entity.name}",
            f"  Attributes: {', '.join(attr_names) or 'none'}",
            f"  Metrics: {', '.join(metric_names) or 'none'}",
            f"  Filters: {', '.join(filter_names) or 'none'}",
        ]
        entity_blocks.append("\n".join(block_lines))

    model_description = "\n\n".join(entity_blocks) or "No entities defined."

    doc_snippet = text[:max_chars]
    text_header = (
        f"DOCUMENT TEXT ({section_label}, up to {max_chars} chars):"
        if section_label
        else f"DOCUMENT TEXT (up to {max_chars} chars):"
    )

    prompt = f"""You are helping build a BUSINESS GLOSSARY for the semantic model below.

SEMANTIC MODEL ELEMENTS:
{model_description}

{text_header}
\"\"\"
{doc_snippet}
\"\"\"

TASK:
From this document section, extract key BUSINESS TERMS, metrics, and dimensions that are relevant
to the semantic model.

For each term, provide:

- term: canonical name (string)
- description: short business definition (string). If the document provides concrete example values,
  append them as a separate block like:
  Examples:
  - value1
  - value2
  (max 5 values). Do NOT invent examples.
- synonyms: alternative spellings, abbreviations, older/newer names (string array)

RULES:
- Only create terms that are clearly business concepts, measures, or dimensions.
- Prefer fewer, high-quality terms over many noisy ones.
- Terms can represent evolving business language, e.g., "Designer", "Data Designer", "Full Designer".

OUTPUT:
Return ONLY valid JSON matching this schema:

{{
  "items": [
    {{
      "term": "canonical name",
      "description": "business definition",
      "synonyms": ["alt1", "alt2"]
    }}
  ]
}}

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

    return prompt


def _get_glossary_llm_id(explicit_llm_id: Optional[str]) -> str:
    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 extraction")


def _call_llm_for_extraction(llm_id: str, prompt: str) -> str:
    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_extraction_response(raw_text: str) -> list[dict]:
    json_text = extract_json_block(raw_text)
    try:
        payload = json.loads(json_text)
    except json.JSONDecodeError as e:
        logger.warning("Failed to parse glossary JSON response: %s", e)
        return []

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

    for item in items:
        try:
            term = (item.get("term") or "").strip()
            if not term:
                continue

            results.append(
                {
                    "term": term,
                    "description": item.get("description") or None,
                    "synonyms": item.get("synonyms") or [],
                }
            )
        except Exception:
            continue

    return results


def extract_terms_from_file(
    *,
    llm_id: Optional[str],
    file_path: str,
    file_name: str,
    spec: SemanticModelSpec,
    max_terms: int = 50,
    max_chunks: int = 20,
    chunk_size: int = MAX_TEXT_CHARS,
    chunk_overlap: int = 0,
) -> tuple[list[dict], int]:
    all_terms: dict[str, dict] = {}

    sections = extract_document_sections(file_path, max_sections=0)

    if not sections:
        logger.warning("No sections extracted from document: %s", file_name)
        return [], 0

    if chunk_size <= 0:
        raise ExecutionError("chunk_size must be > 0")
    if chunk_overlap < 0:
        raise ExecutionError("chunk_overlap must be >= 0")
    if chunk_overlap >= chunk_size:
        raise ExecutionError("chunk_overlap must be smaller than chunk_size")

    chosen_llm_id = _get_glossary_llm_id(llm_id)

    chunks = _build_chunks(
        sections=sections,
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        max_chunks=max_chunks,
    )

    if not chunks:
        logger.warning("No chunks built from document: %s", file_name)
        return [], 0

    logger.info(
        "=== GLOSSARY EXTRACTION START ===\n"
        "  Document: %s\n"
        "  Sections: %d\n"
        "  Chunks: %d\n"
        "  Config: max_chunks=%d, max_terms=%d, chunk_size=%d, chunk_overlap=%d\n"
        "  LLM: %s",
        file_name,
        len(sections),
        len(chunks),
        max_chunks,
        max_terms,
        chunk_size,
        chunk_overlap,
        chosen_llm_id,
    )

    for chunk in chunks:
        section_label = chunk["label"]
        chunk_text = chunk["text"]
        if not chunk_text.strip():
            logger.debug("Skipping empty chunk: %s", section_label)
            continue

        prompt = _build_glossary_extraction_prompt(
            chunk_text,
            spec,
            section_label,
            max_chars=chunk_size,
        )

        logger.info(
            "=== LLM CALL: %s ===\n  Chars: %d | LLM: %s",
            section_label,
            len(chunk_text),
            chosen_llm_id,
        )
        logger.info(
            "=== PROMPT ===\n%s\n=== END PROMPT ===",
            prompt,
        )

        try:
            response_text = _call_llm_for_extraction(chosen_llm_id, prompt)

            logger.info("  Response:\n%s", response_text)

            items = _parse_extraction_response(response_text)
            logger.info("  Terms extracted: %d", len(items))

            for item in items:
                normalized = normalize_term(item["term"])
                if normalized in all_terms:
                    existing = all_terms[normalized]
                    existing["synonyms"] = list(set(existing["synonyms"] + item.get("synonyms", [])))
                else:
                    all_terms[normalized] = item

        except Exception as e:
            logger.warning("Failed to extract from %s: %s", section_label, e)
            continue

    results = list(all_terms.values())[:max_terms]
    logger.info(
        "=== GLOSSARY EXTRACTION COMPLETE ===\n"
        "  Document: %s\n"
        "  Chunks processed: %d\n"
        "  Unique terms: %d (max_terms=%d)",
        file_name,
        len(chunks),
        len(results),
        max_terms,
    )

    return results, len(chunks)


def _section_label(section: DocumentSection) -> str:
    if section.title:
        return f"Section: {section.title}"
    return f"Section {section.index + 1}"


def _split_text(text: str, size: int, overlap: int) -> list[str]:
    if size <= 0:
        return []
    step = max(size - overlap, 1)
    chunks: list[str] = []
    start = 0
    while start < len(text):
        end = start + size
        chunks.append(text[start:end])
        if end >= len(text):
            break
        start += step
    return chunks


def _build_chunks(
    *,
    sections: list[DocumentSection],
    chunk_size: int,
    chunk_overlap: int,
    max_chunks: int,
) -> list[dict[str, str]]:
    chunks: list[dict[str, str]] = []
    current_parts: list[str] = []
    current_len = 0
    current_start_label: Optional[str] = None
    current_end_label: Optional[str] = None

    def flush():
        nonlocal current_parts, current_len, current_start_label, current_end_label
        if not current_parts:
            return
        label = current_start_label or "Chunk"
        if current_end_label and current_end_label != current_start_label:
            label = f"{current_start_label} ... {current_end_label}"
        chunks.append({"label": label, "text": "\n\n".join(current_parts)})
        current_parts = []
        current_len = 0
        current_start_label = None
        current_end_label = None

    for section in sections:
        raw_text = (section.text or "").strip()
        if not raw_text:
            continue
        label = _section_label(section)

        section_block = f"{label}\n{raw_text}"
        if len(section_block) <= chunk_size:
            extra = len(section_block) + (2 if current_parts else 0)
            if current_len + extra <= chunk_size:
                current_parts.append(section_block)
                current_len += extra
                current_start_label = current_start_label or label
                current_end_label = label
            else:
                flush()
                current_parts.append(section_block)
                current_len = len(section_block)
                current_start_label = label
                current_end_label = label
        else:
            flush()
            label_prefix = f"{label}\n"
            available = max(chunk_size - len(label_prefix), 200)
            for i, part in enumerate(_split_text(raw_text, available, chunk_overlap), start=1):
                part_label = f"{label} (part {i})" if i > 1 else label
                chunk_text = f"{part_label}\n{part}"
                chunks.append({"label": part_label, "text": chunk_text})
                if max_chunks > 0 and len(chunks) >= max_chunks:
                    return chunks

        if max_chunks > 0 and len(chunks) >= max_chunks:
            return chunks

    flush()
    if max_chunks > 0:
        return chunks[:max_chunks]
    return chunks
