"""Locked master-attribute vocabularies (allowed values + aliases).

Dashboard: GET/PUT /api/manual-attributes
Import: canonicalize/drop polluted CSV values before attribute write.
Values API: when locked, return vocabulary values only.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from db.models import ManualAttributeVocabulary, ManualAttributeVocabularyValue
from db.source_imports import normalize_column_key

# Built-in seed used by ensure_default_manual_attributes (tests / fresh DBs).
DEFAULT_MANUAL_ATTRIBUTES: List[Dict[str, Any]] = [
    {
        "attribute_code": "assembled_or_rta",
        "label": "Assembled or RTA",
        "is_locked": True,
        "unknown_policy": "drop",
        "covered_codes": ["assembled_or_rta", "assembly_type"],
        "sort_order": 10,
        "notes": "Locked vocabulary: Assembled or Unassembled/RTA only",
        "values": [
            {
                "value": "Assembled",
                "aliases": ["Assembled", "assembled"],
                "sort_order": 10,
            },
            {
                "value": "Unassembled/RTA",
                "aliases": [
                    "Unassembled/RTA",
                    "RTA",
                    "Unassembled",
                    "unassembled",
                    "Ready-to-Assemble",
                    "ready to assemble",
                    "ready-to-assemble",
                ],
                "sort_order": 20,
            },
        ],
    }
]


def _normalize_aliases(values: Any, *, include_canonical: Optional[str] = None) -> List[str]:
    if isinstance(values, str):
        values = [part.strip() for part in values.split(",")]
    if not isinstance(values, list):
        values = []
    seen = set()
    out: List[str] = []
    for item in [*([include_canonical] if include_canonical else []), *values]:
        text = str(item or "").strip()
        if not text:
            continue
        key = text.lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(text)
    return out


def _covered_codes(attribute_code: str, covered: Any) -> List[str]:
    codes = _normalize_aliases(covered, include_canonical=attribute_code)
    normalized: List[str] = []
    seen = set()
    for code in codes:
        key = normalize_column_key(code)
        if key and key not in seen:
            seen.add(key)
            normalized.append(key)
    return normalized


def _value_dict(row: ManualAttributeVocabularyValue) -> Dict[str, Any]:
    aliases = _normalize_aliases(
        row.aliases_json if isinstance(row.aliases_json, list) else [],
        include_canonical=row.value,
    )
    return {
        "value": str(row.value or "").strip(),
        "aliases": aliases,
        "sort_order": int(row.sort_order or 100),
        "is_active": bool(row.is_active),
        "notes": row.notes,
    }


def _attribute_dict(
    vocab: ManualAttributeVocabulary,
    values: List[ManualAttributeVocabularyValue],
) -> Dict[str, Any]:
    return {
        "attribute_code": normalize_column_key(vocab.attribute_code),
        "label": str(vocab.label or "").strip() or vocab.attribute_code,
        "is_locked": bool(vocab.is_locked),
        "unknown_policy": str(vocab.unknown_policy or "drop").strip().lower() or "drop",
        "covered_codes": _covered_codes(vocab.attribute_code, vocab.covered_codes_json),
        "is_active": bool(vocab.is_active),
        "sort_order": int(vocab.sort_order or 100),
        "notes": vocab.notes,
        "values": [
            _value_dict(item)
            for item in sorted(values, key=lambda row: (row.sort_order, row.id))
            if item.is_active
        ],
    }


def list_manual_attributes(session: Session, *, include_inactive: bool = False) -> Dict[str, Any]:
    stmt = select(ManualAttributeVocabulary).order_by(
        ManualAttributeVocabulary.sort_order,
        ManualAttributeVocabulary.attribute_code,
        ManualAttributeVocabulary.id,
    )
    if not include_inactive:
        stmt = stmt.where(ManualAttributeVocabulary.is_active.is_(True))
    vocabs = list(session.scalars(stmt).all())
    if not vocabs:
        return {"attributes": [], "count": 0}
    vocab_ids = [row.id for row in vocabs]
    value_rows = session.scalars(
        select(ManualAttributeVocabularyValue)
        .where(ManualAttributeVocabularyValue.vocabulary_id.in_(vocab_ids))
        .order_by(
            ManualAttributeVocabularyValue.vocabulary_id,
            ManualAttributeVocabularyValue.sort_order,
            ManualAttributeVocabularyValue.id,
        )
    ).all()
    by_vocab: Dict[int, List[ManualAttributeVocabularyValue]] = {}
    for row in value_rows:
        by_vocab.setdefault(row.vocabulary_id, []).append(row)
    attributes = [_attribute_dict(vocab, by_vocab.get(vocab.id, [])) for vocab in vocabs]
    return {"attributes": attributes, "count": len(attributes)}


def ensure_default_manual_attributes(session: Session) -> Dict[str, Any]:
    """Idempotently seed built-in locked vocabularies when missing."""
    existing = {
        normalize_column_key(code)
        for (code,) in session.execute(select(ManualAttributeVocabulary.attribute_code)).all()
        if code
    }
    created = 0
    for item in DEFAULT_MANUAL_ATTRIBUTES:
        code = normalize_column_key(item["attribute_code"])
        if code in existing:
            continue
        _upsert_one_attribute(session, item)
        created += 1
    if created:
        session.flush()
    result = list_manual_attributes(session)
    result["seeded_count"] = created
    return result


def replace_manual_attributes(session: Session, payload: Dict[str, Any]) -> Dict[str, Any]:
    attributes = payload.get("attributes")
    if not isinstance(attributes, list):
        raise ValueError("attributes must be a list")

    replace_all = bool(payload.get("replace_all", False))
    saved = 0
    seen_codes: set[str] = set()
    for item in attributes:
        if not isinstance(item, dict):
            continue
        code = normalize_column_key(item.get("attribute_code") or "")
        if not code or code in seen_codes:
            continue
        seen_codes.add(code)
        _upsert_one_attribute(session, {**item, "attribute_code": code})
        saved += 1

    deleted = 0
    if replace_all:
        if seen_codes:
            stale = session.scalars(
                select(ManualAttributeVocabulary).where(
                    ~ManualAttributeVocabulary.attribute_code.in_(sorted(seen_codes))
                )
            ).all()
        else:
            stale = list(session.scalars(select(ManualAttributeVocabulary)).all())
        for row in stale:
            session.execute(
                delete(ManualAttributeVocabularyValue).where(
                    ManualAttributeVocabularyValue.vocabulary_id == row.id
                )
            )
            session.delete(row)
            deleted += 1

    session.flush()
    result = list_manual_attributes(session)
    result["saved_count"] = saved
    result["deleted_count"] = deleted
    return result


def _upsert_one_attribute(session: Session, item: Dict[str, Any]) -> ManualAttributeVocabulary:
    code = normalize_column_key(item.get("attribute_code") or "")
    if not code:
        raise ValueError("attribute_code is required")
    vocab = session.scalar(
        select(ManualAttributeVocabulary).where(ManualAttributeVocabulary.attribute_code == code)
    )
    if vocab is None:
        vocab = ManualAttributeVocabulary(attribute_code=code)
        session.add(vocab)
        session.flush()
    else:
        session.execute(
            delete(ManualAttributeVocabularyValue).where(
                ManualAttributeVocabularyValue.vocabulary_id == vocab.id
            )
        )

    unknown_policy = str(item.get("unknown_policy") or "drop").strip().lower() or "drop"
    if unknown_policy not in {"drop", "keep"}:
        unknown_policy = "drop"

    vocab.label = str(item.get("label") or "").strip() or code.replace("_", " ").title()
    vocab.is_locked = bool(item.get("is_locked", True))
    vocab.unknown_policy = unknown_policy
    vocab.covered_codes_json = _covered_codes(code, item.get("covered_codes"))
    vocab.is_active = bool(item.get("is_active", True))
    vocab.sort_order = int(item.get("sort_order") or 100)
    vocab.notes = str(item.get("notes") or "").strip() or None
    vocab.raw_payload = item

    seen_values: set[str] = set()
    for index, value_item in enumerate(item.get("values") or []):
        if not isinstance(value_item, dict):
            continue
        value = str(value_item.get("value") or "").strip()
        if not value:
            continue
        value_key = value.lower()
        if value_key in seen_values:
            continue
        seen_values.add(value_key)
        aliases = _normalize_aliases(value_item.get("aliases"), include_canonical=value)
        session.add(
            ManualAttributeVocabularyValue(
                vocabulary_id=vocab.id,
                value=value,
                aliases_json=aliases,
                sort_order=int(value_item.get("sort_order") if value_item.get("sort_order") is not None else index * 10),
                is_active=bool(value_item.get("is_active", True)),
                raw_payload=value_item,
                notes=str(value_item.get("notes") or "").strip() or None,
            )
        )
    session.flush()
    return vocab


def get_locked_vocabulary_for_code(
    session: Session,
    attribute_code: str,
) -> Optional[Dict[str, Any]]:
    """Return locked vocabulary covering this attribute code, if any."""
    code = normalize_column_key(attribute_code)
    if not code:
        return None
    payload = list_manual_attributes(session)
    for item in payload["attributes"]:
        if not item.get("is_locked") or not item.get("is_active"):
            continue
        covered = {normalize_column_key(c) for c in (item.get("covered_codes") or [])}
        covered.add(normalize_column_key(item["attribute_code"]))
        if code in covered:
            return item
    return None


def locked_values_for_code(session: Session, attribute_code: str) -> Optional[List[str]]:
    vocab = get_locked_vocabulary_for_code(session, attribute_code)
    if not vocab:
        return None
    return [str(item["value"]).strip() for item in vocab.get("values") or [] if str(item.get("value") or "").strip()]


def canonicalize_locked_attribute_value(
    session: Session,
    attribute_code: str,
    raw_value: Any,
) -> Tuple[Optional[str], str]:
    """Map a raw value through locked vocabulary.

    Returns (canonical_value_or_None, action) where action is:
      matched | dropped | kept | unlocked
    """
    text = str(raw_value or "").strip()
    vocab = get_locked_vocabulary_for_code(session, attribute_code)
    if not vocab:
        return (text or None, "unlocked")
    if not text:
        return (None, "dropped" if vocab.get("unknown_policy") == "drop" else "kept")

    needle = text.lower()
    for item in vocab.get("values") or []:
        canonical = str(item.get("value") or "").strip()
        aliases = [str(a).strip() for a in (item.get("aliases") or [])]
        candidates = {canonical.lower(), *(a.lower() for a in aliases if a)}
        if needle in candidates:
            return (canonical, "matched")

    if vocab.get("unknown_policy") == "keep":
        return (text, "kept")
    return (None, "dropped")


def apply_manual_attribute_vocabularies_to_payload(
    session: Session,
    payload: Dict[str, Any],
    *,
    locked_vocabs: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
    """Rewrite payload keys covered by locked vocabularies; drop polluted values."""
    if not isinstance(payload, dict) or not payload:
        return payload

    if locked_vocabs is None:
        attributes = list_manual_attributes(session).get("attributes") or []
        locked = [item for item in attributes if item.get("is_locked") and item.get("is_active")]
    else:
        locked = locked_vocabs
    if not locked:
        return payload

    # Build lookup: normalized payload key → original keys
    key_map: Dict[str, List[str]] = {}
    for key in list(payload.keys()):
        normalized = normalize_column_key(key)
        if normalized:
            key_map.setdefault(normalized, []).append(key)

    def _match(vocab: Dict[str, Any], raw_value: str) -> Optional[str]:
        needle = raw_value.lower()
        for item in vocab.get("values") or []:
            canonical = str(item.get("value") or "").strip()
            aliases = [str(a).strip() for a in (item.get("aliases") or [])]
            candidates = {canonical.lower(), *(a.lower() for a in aliases if a)}
            if needle in candidates:
                return canonical
        return None

    for vocab in locked:
        covered = _covered_codes(vocab["attribute_code"], vocab.get("covered_codes"))
        for code in covered:
            original_keys = key_map.get(code) or []
            if not original_keys:
                continue
            current_value = None
            for key in original_keys:
                value = str(payload.get(key) or "").strip()
                if value:
                    current_value = value
                    break
            for key in original_keys:
                payload.pop(key, None)
            if current_value is None:
                continue
            canonical = _match(vocab, current_value)
            if canonical:
                payload[original_keys[0]] = canonical
                continue
            if vocab.get("unknown_policy") == "keep":
                payload[original_keys[0]] = current_value
    return payload
