from __future__ import annotations

import json
import random
import re
from pathlib import Path
from typing import Any


ROOT_DIR = Path(__file__).resolve().parent
CATEGORY_DIR = ROOT_DIR / "categories"
RANDOM_SUBCATEGORY = "random"


def category_json_files() -> list[Path]:
    if not CATEGORY_DIR.exists():
        return []
    return sorted(path for path in CATEGORY_DIR.glob("*.json") if path.is_file())


def read_category_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise ValueError(f"{path} must contain a JSON object")
    return data


def _slug(value: str) -> str:
    text = str(value or "").lower()
    text = re.sub(r"[^a-z0-9]+", "_", text)
    return text.strip("_")[:48] or "custom"


def _list_from(value: Any) -> list[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return value
    return [value]


def _is_false(value: Any) -> bool:
    if isinstance(value, bool):
        return value is False
    if isinstance(value, str):
        return value.strip().lower() in ("false", "0", "no", "off")
    return False


def _entry_text(item: Any) -> str:
    if isinstance(item, dict):
        return str(
            item.get("template")
            or item.get("prompt")
            or item.get("text")
            or item.get("description")
            or item.get("name")
            or ""
        ).strip()
    return str(item).strip()


def _unique_extend(target: list[Any], additions: list[Any]) -> None:
    seen = set()
    for item in target:
        try:
            seen.add(json.dumps(item, sort_keys=True))
        except TypeError:
            seen.add(repr(item))
    for item in additions:
        try:
            marker = json.dumps(item, sort_keys=True)
        except TypeError:
            marker = repr(item)
        if marker not in seen:
            target.append(item)
            seen.add(marker)


def _weighted_choice(rng: random.Random, items: list[Any]) -> Any:
    if not items:
        raise ValueError("Cannot choose from an empty list")
    weights: list[float] = []
    for item in items:
        weight = item.get("weight", 1.0) if isinstance(item, dict) else 1.0
        try:
            weights.append(max(0.0, float(weight)))
        except (TypeError, ValueError):
            weights.append(1.0)
    total = sum(weights)
    if total <= 0:
        return items[rng.randrange(len(items))]
    pick = rng.random() * total
    running = 0.0
    for item, weight in zip(items, weights):
        running += weight
        if pick <= running:
            return item
    return items[-1]


def template_list(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str) -> list[Any]:
    if isinstance(item, dict) and key in item:
        return _list_from(item[key])
    if key in subcategory:
        return _list_from(subcategory[key])
    if key in category:
        return _list_from(category[key])
    return []


def _constraint_int(entry: dict[str, Any], key: str) -> int | None:
    if key not in entry:
        return None
    try:
        return int(entry[key])
    except (TypeError, ValueError):
        return None


def _cast_requirement_matches(requirement: str, women_count: int, men_count: int) -> bool:
    total = women_count + men_count
    requirement = requirement.strip().lower()
    if requirement in ("", "any"):
        return True
    if requirement == "women_only":
        return women_count > 0 and men_count == 0
    if requirement == "men_only":
        return men_count > 0 and women_count == 0
    if requirement == "mixed":
        return women_count > 0 and men_count > 0
    if requirement == "has_women":
        return women_count > 0
    if requirement == "has_men":
        return men_count > 0
    if requirement == "solo":
        return total == 1
    if requirement == "couple":
        return total == 2
    if requirement == "threesome":
        return total == 3
    if requirement == "group":
        return total >= 4
    return True


def _is_toy_assisted_double_couple_text(text: str) -> bool:
    text = text.lower()
    if "toy" not in text:
        return False
    return any(
        token in text
        for token in (
            "double penetration",
            "double-penetration",
            "vaginal and anal penetration",
            "second penetration point",
            "second point of contact",
            "second contact",
        )
    )


def _heuristic_cast_compatible(text: str, women_count: int, men_count: int) -> bool:
    text = text.lower()
    if not text:
        return True
    total = women_count + men_count
    if total == 2 and women_count == 1 and men_count == 1:
        if "{double_act}" in text:
            return False
        if _is_toy_assisted_double_couple_text(text):
            return False
    if total == 1:
        solo_blocked_terms = (
            "partner",
            "partners",
            "two bodies",
            "three bodies",
            "bodies still pressed",
            "bodies pressed",
            "bodies tangled",
            "wet bodies",
            "chests heaving together",
            "straddling a partner",
            "shared climax",
            "between two",
            "from both sides",
            "front-and-back",
            "body contact",
        )
        if any(term in text for term in solo_blocked_terms):
            return False
        solo_toy_terms = ("toy", "dildo", "finger", "fingers", "self")
        if "penetration" in text and not any(term in text for term in solo_toy_terms):
            return False
    if total < 3 and "threesome" in text:
        return False
    if total != 3 and ("centered threesome" in text or "three-way" in text):
        return False
    if total < 3 and ("three bodies" in text or "center partner" in text or "center body" in text):
        return False
    if total < 4 and ("orgy" in text or "group sex" in text or "group-sex" in text or "group pile" in text):
        return False
    if total < 3 and (
        "double penetration" in text
        or "two partners penetrating" in text
        or "front-and-back penetration" in text
        or "one penis in pussy and one penis in ass" in text
        or "pussy and ass filled" in text
        or "vaginal and anal penetration at the same time" in text
        or "front-and-back double penetration" in text
        or "hardcore double penetration" in text
        or "kneeling double penetration" in text
        or "standing supported double penetration" in text
        or "deep double penetration" in text
        or "between two partners" in text
        or "from both sides" in text
    ):
        toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger")
        if not any(term in text for term in toy_terms):
            return False
    if men_count == 0:
        toy_terms = ("strap-on", "strap on", "dildo", "toy", "finger", "fingers")
        penetration_terms = (
            "vaginal penetration",
            "deep vaginal sex",
            "penetrative sex",
            "pussy penetration",
            "pussy stretched",
            "vaginal thrusting",
            "full-body penetrative",
            "close-contact vaginal",
            "penetration clearly visible",
            "explicit penetrative contact",
        )
        if any(term in text for term in penetration_terms) and not any(term in text for term in toy_terms):
            return False
        male_terms = (
            " penis",
            "penis ",
            "penises",
            "cum",
            "creampie",
            "facial",
            "blowjob",
            "fellatio",
            "deepthroat",
            "ejaculation",
            "semen",
        )
        if any(term in text for term in male_terms) and not any(term in text for term in toy_terms):
            return False
    elif men_count < 2 and "penises" in text:
        return False
    if women_count == 0:
        if "penetrative sex" in text and not any(term in text for term in ("anal", "ass", "male/male", "men")):
            return False
        female_terms = (
            "pussy",
            "vaginal",
            "vagina",
            "cunnilingus",
            "clit",
            "clitoris",
            "breasts",
            "breast ",
            "nipples",
            "nipple",
            "underboob",
        )
        if any(term in text for term in female_terms):
            return False
    return True


def compatible_entry(entry: Any, women_count: int, men_count: int) -> bool:
    if not isinstance(entry, dict):
        return _heuristic_cast_compatible(_entry_text(entry), women_count, men_count)
    total = women_count + men_count
    for key, value in (
        ("min_women", women_count),
        ("min_men", men_count),
        ("min_people", total),
    ):
        minimum = _constraint_int(entry, key)
        if minimum is not None and value < minimum:
            return False
    for key, value in (
        ("max_women", women_count),
        ("max_men", men_count),
        ("max_people", total),
    ):
        maximum = _constraint_int(entry, key)
        if maximum is not None and value > maximum:
            return False
    requirements = _list_from(entry.get("cast", [])) + _list_from(entry.get("requires", []))
    if requirements and not all(_cast_requirement_matches(str(req), women_count, men_count) for req in requirements):
        return False
    if any(key in entry for key in ("subcategories", "item_templates", "item_axes")):
        return True
    return _heuristic_cast_compatible(_entry_text(entry), women_count, men_count)


def compatible_entries(entries: list[Any], women_count: int, men_count: int) -> list[Any]:
    filtered = [entry for entry in entries if compatible_entry(entry, women_count, men_count)]
    return filtered or entries


def merged_axes(category: dict[str, Any], subcategory: dict[str, Any], item: Any) -> dict[str, list[Any]]:
    axes: dict[str, list[Any]] = {}
    for source in (category, subcategory, item if isinstance(item, dict) else None):
        if not isinstance(source, dict):
            continue
        raw_axes = source.get("item_axes", {})
        if raw_axes is None:
            continue
        if not isinstance(raw_axes, dict):
            raise ValueError("item_axes must be a JSON object")
        for key, values in raw_axes.items():
            axes[str(key)] = _list_from(values)
    return axes


def _normalize_subcategories(category: dict[str, Any]) -> list[dict[str, Any]]:
    raw = category.get("subcategories", [])
    if isinstance(raw, dict):
        raw = [
            {"name": name, **(value if isinstance(value, dict) else {"items": value})}
            for name, value in raw.items()
        ]
    subcategories: list[dict[str, Any]] = []
    for entry in _list_from(raw):
        if isinstance(entry, str):
            sub = {"name": entry, "items": [entry]}
        elif isinstance(entry, dict):
            sub = dict(entry)
        else:
            raise ValueError(f"Subcategory must be an object or string: {entry!r}")
        name = str(sub.get("name") or sub.get("slug") or "General").strip()
        sub["name"] = name
        sub["slug"] = str(sub.get("slug") or _slug(name))
        if "items" not in sub and "prompts" in sub:
            sub["items"] = sub["prompts"]
        if "items" not in sub:
            sub["items"] = [name]
        subcategories.append(sub)
    if not subcategories:
        name = str(category.get("name") or "General")
        subcategories.append({"name": "General", "slug": "general", "items": [name]})
    return subcategories


def _normalize_categories(raw_categories: Any) -> list[dict[str, Any]]:
    if isinstance(raw_categories, dict):
        iterable = [
            {"name": name, **(value if isinstance(value, dict) else {"subcategories": value})}
            for name, value in raw_categories.items()
        ]
    else:
        iterable = _list_from(raw_categories)

    categories: list[dict[str, Any]] = []
    for entry in iterable:
        if not isinstance(entry, dict):
            raise ValueError(f"Category must be an object: {entry!r}")
        category = dict(entry)
        name = str(category.get("name") or category.get("slug") or "Custom").strip()
        category["name"] = name
        category["slug"] = str(category.get("slug") or _slug(name))
        category["subcategories"] = _normalize_subcategories(category)
        categories.append(category)
    return categories


def load_category_library() -> list[dict[str, Any]]:
    categories: list[dict[str, Any]] = []
    for path in category_json_files():
        data = read_category_json(path)
        categories.extend(_normalize_categories(data.get("categories", [])))
    return categories


def load_named_pool_library(key: str) -> dict[str, list[Any]]:
    pools: dict[str, list[Any]] = {}
    for path in category_json_files():
        data = read_category_json(path)
        raw_pools = data.get(key, {})
        if not raw_pools:
            continue
        if not isinstance(raw_pools, dict):
            raise ValueError(f"{key} in {path} must be an object")
        for name, entries in raw_pools.items():
            pool_name = str(name).strip()
            if not pool_name:
                continue
            pools.setdefault(pool_name, [])
            _unique_extend(pools[pool_name], _list_from(entries))
    return pools


def load_scene_pool_library() -> dict[str, list[Any]]:
    return load_named_pool_library("scene_pools")


def load_expression_pool_library() -> dict[str, list[Any]]:
    return load_named_pool_library("expression_pools")


def load_composition_pool_library() -> dict[str, list[Any]]:
    return load_named_pool_library("composition_pools")


def find_category(categories: list[dict[str, Any]], name_or_slug: str) -> dict[str, Any] | None:
    wanted = name_or_slug.strip().lower()
    for category in categories:
        if category["name"].lower() == wanted or category["slug"].lower() == wanted:
            return category
    return None


def exact_subcategory_selector(category: dict[str, Any], subcategory: dict[str, Any]) -> str:
    return f"{category.get('name')} / {subcategory.get('name')}"


def split_exact_subcategory_choice(
    categories: list[dict[str, Any]],
    subcategory_choice: str,
) -> tuple[dict[str, Any], str] | None:
    choice = str(subcategory_choice or "").strip()
    if not choice or " / " not in choice:
        return None
    candidates: list[tuple[int, dict[str, Any], str]] = []
    for category in categories:
        for category_label in (category.get("name", ""), category.get("slug", "")):
            category_label = str(category_label).strip()
            prefix = f"{category_label} / "
            if category_label and choice.lower().startswith(prefix.lower()):
                candidates.append((len(prefix), category, choice[len(prefix) :].strip()))
    if candidates:
        _length, category, subcategory_name = max(candidates, key=lambda candidate: candidate[0])
        return category, subcategory_name
    return None


def _base_cast_counts(women_count: int, men_count: int) -> tuple[int, int]:
    women_count = max(0, int(women_count))
    men_count = max(0, int(men_count))
    if women_count + men_count == 0:
        women_count = 1
    return women_count, men_count


def _counts_for_exact_subcategory(
    subcategory: dict[str, Any],
    women_count: int,
    men_count: int,
) -> tuple[int, int]:
    women_count, men_count = _base_cast_counts(women_count, men_count)

    min_women = _constraint_int(subcategory, "min_women")
    if min_women is not None and women_count < min_women:
        women_count = min_women
    min_men = _constraint_int(subcategory, "min_men")
    if min_men is not None and men_count < min_men:
        men_count = min_men

    min_people = _constraint_int(subcategory, "min_people")
    if min_people is not None:
        missing = min_people - (women_count + men_count)
        if missing > 0:
            if women_count > 0 or men_count == 0:
                women_count += missing
            else:
                men_count += missing
    return women_count, men_count


def find_subcategory(
    categories: list[dict[str, Any]],
    category_choice: str,
    subcategory_choice: str,
    category_rng: random.Random,
    subcategory_rng: random.Random,
    women_count: int = 1,
    men_count: int = 1,
    random_subcategory: str = RANDOM_SUBCATEGORY,
) -> tuple[dict[str, Any], dict[str, Any], int, int]:
    women_count, men_count = _base_cast_counts(women_count, men_count)
    if subcategory_choice and subcategory_choice != random_subcategory and " / " in subcategory_choice:
        exact_choice = split_exact_subcategory_choice(categories, subcategory_choice)
        if not exact_choice:
            category_name = str(subcategory_choice).split(" / ", 1)[0]
            raise ValueError(f"Unknown category in subcategory picker: {category_name}")
        category, subcategory_name = exact_choice
        wanted = subcategory_name.strip().lower()
        for subcategory in category["subcategories"]:
            if subcategory["name"].lower() == wanted or subcategory["slug"].lower() == wanted:
                adjusted_women_count, adjusted_men_count = _counts_for_exact_subcategory(
                    subcategory,
                    women_count,
                    men_count,
                )
                if not compatible_entry(subcategory, adjusted_women_count, adjusted_men_count):
                    raise ValueError(
                        f"Subcategory '{subcategory['name']}' is not compatible with "
                        f"women_count={women_count}, men_count={men_count}"
                    )
                return category, subcategory, adjusted_women_count, adjusted_men_count
        raise ValueError(f"Unknown subcategory '{subcategory_name}' for category '{category['name']}'")

    if category_choice == "custom_random":
        if not categories:
            raise ValueError("No custom categories found in categories/*.json")
        category = _weighted_choice(category_rng, categories)
    else:
        category = find_category(categories, category_choice)
        if not category:
            raise ValueError(f"Unknown custom category: {category_choice}")
    subcategories = compatible_entries(category["subcategories"], women_count, men_count)
    subcategory = _weighted_choice(subcategory_rng, subcategories)
    return category, subcategory, women_count, men_count


def merged_field(category: dict[str, Any], subcategory: dict[str, Any], item: Any, key: str, default: Any = None) -> Any:
    if isinstance(item, dict) and key in item:
        return item[key]
    if key in subcategory:
        return subcategory[key]
    if key in category:
        return category[key]
    return default


def _sources_with_inheritance(
    category: dict[str, Any],
    subcategory: dict[str, Any],
    item: Any,
    inherit_key: str,
) -> tuple[Any, ...]:
    item_source = item if isinstance(item, dict) else None
    if item_source is not None and _is_false(item_source.get(inherit_key)):
        return (item_source,)
    if _is_false(subcategory.get(inherit_key)):
        return (subcategory, item_source)
    return (category, subcategory, item_source)


def configured_pool(
    category: dict[str, Any],
    subcategory: dict[str, Any],
    item: Any,
    direct_key: str,
    pool_key: str,
    pool_library: dict[str, list[Any]],
    inherit_key: str,
) -> list[Any]:
    entries: list[Any] = []
    singular_pool_key = pool_key[:-1] if pool_key.endswith("s") else pool_key
    for source in _sources_with_inheritance(category, subcategory, item, inherit_key):
        if not isinstance(source, dict):
            continue
        if direct_key in source:
            _unique_extend(entries, _list_from(source[direct_key]))
        refs = _list_from(source.get(singular_pool_key)) + _list_from(source.get(pool_key))
        for ref in refs:
            ref_name = str(ref).strip()
            if ref_name not in pool_library:
                raise ValueError(f"Unknown {singular_pool_key} '{ref_name}'")
            _unique_extend(entries, pool_library[ref_name])
    return entries
