from __future__ import annotations

import json
import re
from typing import Any

try:
    from .category_library import load_composition_pool_library, load_scene_pool_library
except ImportError:  # Allows local smoke tests from the repository root.
    from category_library import load_composition_pool_library, load_scene_pool_library


LOCATION_POOL_PRESETS = {
    "custom_only": (),
    "all_json_locations": ("*",),
    "casual_all": ("casual_",),
    "casual_urban": ("casual_urban_scenes",),
    "casual_summer": ("casual_summer_scenes",),
    "casual_home": ("casual_lounge_scenes",),
    "casual_smart": ("casual_smart_scenes",),
    "creator_softcore": ("softcore_creator_scenes", "mirror_scenes", "boudoir_bedroom_scenes"),
    "mirror_rooms": ("mirror_scenes", "hardcore_mirror_scenes"),
    "boudoir_bedroom": ("boudoir_bedroom_scenes", "hardcore_bed_scenes"),
    "fetish_studio": ("fetish_studio_scenes",),
    "costume_backstage": ("costume_backstage_scenes",),
    "hardcore_all": ("hardcore_",),
    "hardcore_private": ("hardcore_private_scenes",),
    "hardcore_bed": ("hardcore_bed_scenes",),
    "hardcore_penetrative": ("hardcore_penetrative_scenes",),
    "hardcore_oral": ("hardcore_oral_scenes",),
    "hardcore_anal": ("hardcore_anal_scenes",),
    "hardcore_threesome": ("hardcore_threesome_scenes",),
    "hardcore_group": ("hardcore_group_scenes",),
    "hardcore_climax": ("hardcore_climax_scenes",),
}

COMPOSITION_POOL_PRESETS = {
    "custom_only": (),
    "all_json_compositions": ("*",),
    "casual_all": ("casual_", "streetwear_", "summer_", "cozy_home_", "smart_casual_", "athleisure_"),
    "creator_softcore": ("softcore_creator_compositions", "boudoir_body_compositions"),
    "hardcore_all": ("hardcore_",),
    "hardcore_explicit": ("hardcore_explicit_compositions",),
    "no_outfit_check": (),
}

COMPOSITION_INLINE_PRESETS = {
    "no_outfit_check": [
        "environment-led frame with no outfit-check wording",
        "mid-distance scene composition with the room context readable",
        "partly occluded candid frame through foreground architecture",
        "long perspective frame using repeating background structure",
        "waist-up or three-quarter frame without bag, shoes, or footwear emphasis",
    ],
}

THEMATIC_LOCATION_PRESETS = {
    "classical_library": {
        "locations": [
            {"slug": "classical_large_library", "prompt": "grand classical library hall with towering dark-wood bookshelves, carved columns, rolling ladders, marble floor, warm brass lamps, arched windows, and deep quiet academic atmosphere"},
            {"slug": "old_world_reading_room", "prompt": "large old-world reading room with floor-to-ceiling bookshelves, heavy wooden tables, green banker lamps, leather chairs, tall arched windows, and warm amber evening light"},
            {"slug": "hidden_library_stacks", "prompt": "quiet library stacks with endless tall bookshelves, narrow aisles, rolling ladders, brass lamps, and hidden sightlines between shelves"},
        ],
        "compositions": [
            "narrow aisle frame between towering bookshelves",
            "over-the-shoulder view through foreground books",
            "warm lamp-lit reading-table composition",
            "long vanishing-point frame down repeated library stacks",
            "partly hidden frame behind carved columns and shelf edges",
        ],
    },
    "creator_bedroom": {
        "locations": [
            {"slug": "creator_bedroom_ring_light", "prompt": "private creator bedroom with a ring light, phone tripod, rumpled bedding, and warm lamps"},
            {"slug": "hotel_bed_phone_tripod", "prompt": "hotel bed content setup with a phone on a mini tripod, city lights, and satin bedding"},
            {"slug": "studio_bedroom_backdrop", "prompt": "small creator studio with a bed, seamless backdrop, ring light, and visible phone stand"},
        ],
        "compositions": [
            "creator bedroom frame with bed edge and phone tripod readable",
            "vertical creator-shot frame with ring light and warm lamps behind the body",
            "bedside content setup composition with bedding and tripod placement visible",
            "close room-context frame keeping the phone setup and bed plane clear",
        ],
    },
    "mirror_room": {
        "locations": [
            {"slug": "large_bedroom_mirror_selfie", "prompt": "large bedroom mirror with the phone visible, bed behind the subject, and warm side lamps"},
            {"slug": "neon_mirror_wall", "prompt": "neon mirror wall with glossy floor reflections and saturated magenta-blue edge light"},
            {"slug": "gold_vanity_mirror", "prompt": "gold-framed vanity mirror with makeup lights, silk fabric, and close reflected framing"},
        ],
        "compositions": [
            "mirror-room frame with the reflected phone angle and room depth aligned",
            "full-length mirror composition keeping reflection lines readable",
            "vanity-mirror frame with bulbs and reflected body plane visible",
            "glossy mirror-wall composition with floor reflection line at the lower edge",
        ],
    },
    "workspace_lounge": {
        "locations": [
            {"slug": "coworking_lounge_window", "prompt": "coworking lounge with tall windows, warm desks, laptop tables, glass partition seams, repeated desk rows, plants, and soft shared-office depth"},
        ],
        "compositions": [
            "coworking lounge frame with the subjects near a desk edge and tall-window depth behind them",
            "mid-distance workspace lounge composition with laptop tables and glass partition seams readable around the bodies",
            "diagonal desk-row frame using repeated work tables, plants, and tall windows for room continuity",
            "foreground desk-edge composition with the subject dominant and coworking lounge depth still readable",
        ],
    },
    "boudoir_bedroom": {
        "locations": [
            {"slug": "warm_boudoir_canopy_bed", "prompt": "warm boudoir bedroom with satin sheets, canopy curtains, low lamplight, and bedside phone framing"},
            {"slug": "velvet_headboard_bedroom", "prompt": "velvet headboard bedroom with gold lamps, rumpled bedding, and close sensual framing"},
            {"slug": "hotel_satin_bedroom", "prompt": "luxury hotel bedroom with satin bedding, city glow, and a visible mirror near the bed"},
        ],
        "compositions": [
            "boudoir bedroom frame with sheet folds and warm bedroom depth visible",
            "bed-edge composition with pillows, lamp glow, and headboard depth",
            "low bedroom frame using bedding lines as the foreground anchor",
            "hotel-bed composition with satin sheets and mirror edge readable",
        ],
    },
    "fetish_studio": {
        "locations": [
            {"slug": "black_latex_studio_floor", "prompt": "dark private studio with glossy black floor reflections, rim light, and a phone tripod"},
            {"slug": "red_velvet_lacquer_room", "prompt": "red velvet room with black lacquer furniture, low spotlights, and reflective surfaces"},
            {"slug": "chrome_fetish_set", "prompt": "chrome studio set with reflective panels, black curtains, and hard-edged erotic lighting"},
        ],
        "compositions": [
            "private studio frame with glossy floor reflection and controlled rim light",
            "lacquer-room composition with reflective furniture and backdrop depth",
            "chrome studio frame with panel seams and lighting stands readable",
            "low studio-floor composition keeping reflection lines and set geometry clear",
        ],
    },
    "semi_public_affair": {
        "locations": [
            {"slug": "hotel_corridor_affair", "prompt": "upscale hotel corridor with repeating numbered doors, patterned carpet, brass wall lamps, luggage carts, and a secluded corner near a service alcove"},
            {"slug": "hotel_service_hall", "prompt": "luxury hotel service corridor with repeating linen carts, beige doors, utility shelves, wall sconces, and a private turn away from the main hallway"},
            {"slug": "parking_garage_hidden", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted floor lines, low fluorescent light, and shadowed blind spots"},
            {"slug": "office_afterhours_affair", "prompt": "empty corporate office after hours with rows of glass partitions, repeating desks, blinds, copier alcove, muted city light, and no visible coworkers"},
            {"slug": "library_stacks_secret", "prompt": "classical library stacks with endless tall bookshelves, narrow aisles, rolling ladders, carved columns, warm brass lamps, and hidden sightlines between shelves"},
        ],
        "compositions": [
            "partly concealed frame from behind a doorway edge",
            "long corridor vanishing-point composition with repeated doors",
            "hidden alcove frame with foreground obstruction",
            "surveillance-like candid angle from across the empty space",
            "tight frame using pillars, shelves, or walls to block side visibility",
        ],
    },
    "hotel_corridor": {
        "locations": [
            {"slug": "upscale_hotel_corridor", "prompt": "upscale hotel corridor with repeating doors, patterned carpet, brass wall lamps, quiet service alcoves, and warm late-night light"},
            {"slug": "hotel_service_alcove", "prompt": "hotel service alcove with linen carts, beige utility doors, folded towels, soft wall sconces, and a secluded turn off the main corridor"},
            {"slug": "boutique_hotel_stair_landing", "prompt": "boutique hotel stair landing with repeating railings, framed wall panels, low amber lamps, and a quiet corner between floors"},
        ],
        "compositions": [
            "long hallway frame with repeated doors receding behind the body",
            "corner-alcove composition partly hidden by a wall edge",
            "low corridor angle with patterned carpet leading lines",
            "over-the-shoulder frame toward a closed hotel-room door",
        ],
    },
    "parking_garage": {
        "locations": [
            {"slug": "empty_parking_garage", "prompt": "empty multi-level parking garage with repeating concrete pillars, parked cars, painted bay lines, low fluorescent light, and deep shadowed corners"},
            {"slug": "underground_garage_corner", "prompt": "underground parking garage corner with numbered pillars, glossy concrete floor, parked cars, and blue-green fluorescent light"},
            {"slug": "rooftop_parking_deck_night", "prompt": "rooftop parking deck at night with repeated concrete barriers, distant city lights, painted lines, and open wind"},
        ],
        "compositions": [
            "pillar-framed composition with repeated concrete columns",
            "low angle across painted parking lines",
            "hidden corner frame between parked cars",
            "wide empty garage frame with strong fluorescent perspective",
        ],
    },
    "theater_backstage": {
        "locations": [
            {"slug": "old_theater_backstage", "prompt": "old theater backstage with repeated velvet curtains, prop racks, costume rails, bulb mirrors, dark wings, and narrow hidden passages"},
            {"slug": "cabaret_backstage_wings", "prompt": "cabaret backstage wings with red curtains, costume racks, vanity bulbs, stage ropes, and warm theatrical shadows"},
            {"slug": "prop_storage_corridor", "prompt": "theater prop storage corridor with stacked trunks, repeated scenery flats, rolling racks, and dim practical lamps"},
        ],
        "compositions": [
            "frame between layered velvet curtains",
            "backstage mirror-bulb composition with costume racks behind",
            "hidden wing angle looking toward the stage light spill",
            "narrow prop-aisle frame with repeated vertical flats",
        ],
    },
    "wine_cellar": {
        "locations": [
            {"slug": "private_wine_cellar", "prompt": "private wine cellar with repeating bottle racks, arched brick walls, narrow aisles, dim amber lamps, and secluded corners between shelves"},
            {"slug": "restaurant_wine_storage", "prompt": "restaurant wine storage room with stacked bottle shelves, crate rows, stone floor, soft utility light, and hidden service-door access"},
            {"slug": "arched_cellar_corridor", "prompt": "arched cellar corridor with repeated brick niches, wine racks, low golden lamps, and cool shadowed depth"},
        ],
        "compositions": [
            "narrow aisle frame between repeated bottle racks",
            "arched brick corridor composition with warm lamps",
            "foreground bottle-rack occlusion framing the body",
            "low cellar angle with shelves receding behind",
        ],
    },
    "museum_archive": {
        "locations": [
            {"slug": "museum_archive_room", "prompt": "museum archive room with repeating storage shelves, labeled boxes, rolling ladders, long work tables, soft overhead lights, and hidden aisles"},
            {"slug": "gallery_storage_backroom", "prompt": "gallery storage backroom with stacked frames, rolling racks, crate labels, clean concrete floor, and muted work lights"},
            {"slug": "rare_books_archive", "prompt": "rare-books archive with compact shelving, catalog drawers, reading lamps, archival boxes, and narrow private aisles"},
        ],
        "compositions": [
            "hidden archive-aisle frame between storage shelves",
            "table-edge composition with labeled boxes in the background",
            "foreground crate or shelf occlusion",
            "long compact-shelving perspective with repeated rows",
        ],
    },
    "laundromat_late_night": {
        "locations": [
            {"slug": "late_night_laundromat", "prompt": "late-night laundromat with repeating washing machines, chrome reflections, tiled floor, fluorescent lights, empty aisles, and a secluded back corner"},
            {"slug": "coin_laundry_back_row", "prompt": "coin laundry back row with stacked dryers, plastic folding tables, detergent shelves, buzzing fluorescent light, and no other customers"},
            {"slug": "laundromat_mirror_windows", "prompt": "quiet laundromat with mirrored machine doors, repeated round windows, tile floor, and cool blue night light through front glass"},
        ],
        "compositions": [
            "repeating washer-door perspective behind the body",
            "folding-table edge frame with chrome reflections",
            "low tiled-floor angle down an empty machine row",
            "back-corner composition partly hidden by laundry machines",
        ],
    },
    "train_station_lockers": {
        "locations": [
            {"slug": "train_station_locker_corridor", "prompt": "quiet train-station locker corridor with repeating metal lockers, tiled walls, vending machines, fluorescent light, and a hidden side alcove"},
            {"slug": "empty_platform_underpass", "prompt": "empty station underpass with tiled walls, repeated poster frames, stair railings, fluorescent lights, and late-night quiet"},
            {"slug": "station_service_passage", "prompt": "station service passage with repeating utility doors, metal lockers, warning stripes, and cool overhead light"},
        ],
        "compositions": [
            "locker-row vanishing-point composition",
            "side-alcove frame partly blocked by metal lockers",
            "fluorescent underpass frame with repeated tile lines",
            "candid angle from behind a vending machine edge",
        ],
    },
    "nightclub_back_hall": {
        "locations": [
            {"slug": "nightclub_back_hall", "prompt": "nightclub back hallway with black doors, repeated neon strips, coat-check racks, textured walls, and distant colored dance-floor light"},
            {"slug": "club_vip_corridor", "prompt": "VIP club corridor with velvet ropes, mirrored wall panels, low red light, repeated booths, and a private bend in the hallway"},
            {"slug": "music_venue_greenroom_hall", "prompt": "music venue greenroom corridor with stickered doors, cable cases, dim practical lamps, and repeated black curtains"},
        ],
        "compositions": [
            "neon hallway frame with repeated dark doors",
            "partly hidden VIP-booth angle",
            "mirror-panel composition with colored light streaks",
            "tight backstage corridor frame with curtains at the edges",
        ],
    },
    "restaurant_private_booth": {
        "locations": [
            {"slug": "restaurant_private_booth", "prompt": "dim restaurant private booth with high banquettes, repeating table lamps, dark wood partitions, folded napkins, and secluded sightlines"},
            {"slug": "empty_bistro_back_corner", "prompt": "empty bistro back corner with tiled floor, small round tables, brass lamps, mirrored walls, and a hidden booth"},
            {"slug": "afterhours_dining_room", "prompt": "after-hours dining room with stacked chairs, repeated tables, low amber sconces, and a quiet service doorway"},
        ],
        "compositions": [
            "booth-partition frame with high seat backs blocking the sides",
            "table-edge composition with lamps repeating behind",
            "mirror-wall restaurant angle with dark wood partitions",
            "after-hours dining-room perspective through empty tables",
        ],
    },
}


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 _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 location_pool_preset_choices() -> list[str]:
    pool_choices = [f"pool:{key}" for key in sorted(load_scene_pool_library())]
    return list(LOCATION_POOL_PRESETS) + pool_choices


def composition_pool_preset_choices() -> list[str]:
    pool_choices = [f"pool:{key}" for key in sorted(load_composition_pool_library())]
    return list(COMPOSITION_POOL_PRESETS) + pool_choices


def location_theme_choices() -> list[str]:
    return list(THEMATIC_LOCATION_PRESETS)


def location_pool_names_for_preset(preset: str) -> list[str]:
    scene_pools = load_scene_pool_library()
    preset = str(preset or "custom_only")
    if preset.startswith("pool:"):
        pool_name = preset.split(":", 1)[1].strip()
        return [pool_name] if pool_name in scene_pools else []
    selectors = LOCATION_POOL_PRESETS.get(preset, ())
    names: list[str] = []
    for selector in selectors:
        if selector == "*":
            _unique_extend(names, sorted(scene_pools))
        elif selector.endswith("_"):
            _unique_extend(names, sorted(name for name in scene_pools if name.startswith(selector)))
        elif selector in scene_pools:
            _unique_extend(names, [selector])
    return names


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


def json_line_entries(line: str, field_name: str) -> list[Any] | None:
    line = line.strip()
    if not line or line[0] not in "[{":
        return None
    try:
        parsed = json.loads(line)
    except json.JSONDecodeError as exc:
        raise ValueError(f"Invalid JSON line in {field_name}: {exc}") from exc
    if isinstance(parsed, list):
        return parsed
    return [parsed]


def normalize_custom_location_entry(value: Any) -> dict[str, Any]:
    if isinstance(value, dict):
        entry = dict(value)
        prompt = entry_prompt_text(entry)
        if not prompt:
            raise ValueError(f"Custom location JSON entry is missing prompt/text/description/name: {value!r}")
        entry["slug"] = _slug(str(entry.get("slug") or entry.get("name") or prompt))
        entry["prompt"] = prompt
        return entry
    prompt = str(value or "").strip()
    if not prompt:
        raise ValueError("Custom location entry cannot be empty")
    return {"slug": _slug(prompt), "prompt": prompt}


def custom_location_entries(custom_locations: str) -> list[dict[str, Any]]:
    entries: list[dict[str, Any]] = []
    for raw_line in str(custom_locations or "").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        json_entries = json_line_entries(line, "custom_locations")
        if json_entries is not None:
            entries.extend(normalize_custom_location_entry(entry) for entry in json_entries)
            continue
        slug = ""
        prompt = line
        if ":" in line:
            maybe_slug, maybe_prompt = line.split(":", 1)
            if maybe_slug.strip() and maybe_prompt.strip():
                slug = _slug(maybe_slug)
                prompt = maybe_prompt.strip()
        prompt = prompt.strip()
        if prompt:
            entries.append({"slug": slug or _slug(prompt), "prompt": prompt})
    return entries


def scene_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
    scene_pools = load_scene_pool_library()
    entries: list[Any] = []
    for pool_name in pool_names:
        if pool_name not in scene_pools:
            continue
        _unique_extend(entries, scene_pools[pool_name])
    return entries


def build_location_pool_json(
    enabled: bool = True,
    combine_mode: str = "replace",
    preset: str = "custom_only",
    custom_locations: str = "",
    location_config: str | dict[str, Any] | None = "",
) -> str:
    incoming = parse_location_config(location_config)
    combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
    pool_names = location_pool_names_for_preset(preset)
    entries = scene_entries_for_pool_names(pool_names)
    _unique_extend(entries, custom_location_entries(custom_locations))

    if combine_mode == "add" and incoming.get("enabled"):
        apply_mode = str(incoming.get("apply_mode") or "replace")
        merged_pool_names = _list_from(incoming.get("pool_names"))
        _unique_extend(merged_pool_names, pool_names)
        merged_entries = _list_from(incoming.get("scene_entries"))
        _unique_extend(merged_entries, entries)
    else:
        apply_mode = "replace" if combine_mode == "replace" else "add"
        merged_pool_names = pool_names
        merged_entries = entries

    active = bool(enabled) and bool(merged_entries)
    theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else ""
    summary = (
        f"{apply_mode}; pools={len(merged_pool_names)}; locations={len(merged_entries)}"
        if active
        else "disabled or empty"
    )
    return json.dumps(
        {
            "enabled": active,
            "apply_mode": apply_mode,
            "pool_names": merged_pool_names,
            "scene_entries": merged_entries,
            "summary": summary,
            "theme": theme,
        },
        ensure_ascii=True,
        sort_keys=True,
    )


def parse_location_config(location_config: str | dict[str, Any] | None) -> dict[str, Any]:
    if not location_config:
        return {"enabled": False, "apply_mode": "replace", "pool_names": [], "scene_entries": [], "theme": ""}
    if isinstance(location_config, dict):
        raw = dict(location_config)
    else:
        try:
            raw = json.loads(str(location_config))
        except json.JSONDecodeError as exc:
            raise ValueError(f"Invalid location_config JSON: {exc}") from exc
    if not isinstance(raw, dict):
        raise ValueError("location_config must be a JSON object")
    entries = _list_from(raw.get("scene_entries"))
    if not entries and raw.get("pool_names"):
        entries = scene_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
    return {
        "enabled": bool(raw.get("enabled")) and bool(entries),
        "apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
        "pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
        "scene_entries": entries,
        "summary": str(raw.get("summary") or ""),
        "theme": str(raw.get("theme") or ""),
    }


def location_config_active(location_config: dict[str, Any]) -> bool:
    return bool(location_config.get("enabled")) and bool(location_config.get("scene_entries"))


def composition_pool_names_for_preset(preset: str) -> list[str]:
    composition_pools = load_composition_pool_library()
    preset = str(preset or "custom_only")
    if preset.startswith("pool:"):
        pool_name = preset.split(":", 1)[1].strip()
        return [pool_name] if pool_name in composition_pools else []
    selectors = COMPOSITION_POOL_PRESETS.get(preset, ())
    names: list[str] = []
    for selector in selectors:
        if selector == "*":
            _unique_extend(names, sorted(composition_pools))
        elif selector.endswith("_"):
            _unique_extend(names, sorted(name for name in composition_pools if name.startswith(selector)))
        elif selector in composition_pools:
            _unique_extend(names, [selector])
    return names


def normalize_custom_composition_entry(value: Any) -> Any:
    if isinstance(value, dict):
        entry = dict(value)
        prompt = entry_prompt_text(entry)
        if not prompt:
            raise ValueError(f"Custom composition JSON entry is missing prompt/text/description/name: {value!r}")
        entry["prompt"] = prompt
        return entry
    text = str(value or "").strip()
    if not text:
        raise ValueError("Custom composition entry cannot be empty")
    return text


def custom_composition_entries(custom_compositions: str) -> list[Any]:
    entries: list[Any] = []
    for raw_line in str(custom_compositions or "").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        json_entries = json_line_entries(line, "custom_compositions")
        if json_entries is not None:
            entries.extend(normalize_custom_composition_entry(entry) for entry in json_entries)
            continue
        entries.append(line)
    return entries


def composition_entries_for_pool_names(pool_names: list[str]) -> list[Any]:
    composition_pools = load_composition_pool_library()
    entries: list[Any] = []
    for pool_name in pool_names:
        if pool_name not in composition_pools:
            continue
        _unique_extend(entries, composition_pools[pool_name])
    return entries


def build_composition_pool_json(
    enabled: bool = True,
    combine_mode: str = "replace",
    preset: str = "custom_only",
    custom_compositions: str = "",
    composition_config: str | dict[str, Any] | None = "",
) -> str:
    incoming = parse_composition_config(composition_config)
    combine_mode = combine_mode if combine_mode in ("replace", "add") else "replace"
    pool_names = composition_pool_names_for_preset(preset)
    entries = composition_entries_for_pool_names(pool_names)
    _unique_extend(entries, COMPOSITION_INLINE_PRESETS.get(str(preset or ""), []))
    _unique_extend(entries, custom_composition_entries(custom_compositions))

    if combine_mode == "add" and incoming.get("enabled"):
        apply_mode = str(incoming.get("apply_mode") or "replace")
        merged_pool_names = _list_from(incoming.get("pool_names"))
        _unique_extend(merged_pool_names, pool_names)
        merged_entries = _list_from(incoming.get("composition_entries"))
        _unique_extend(merged_entries, entries)
    else:
        apply_mode = "replace" if combine_mode == "replace" else "add"
        merged_pool_names = pool_names
        merged_entries = entries

    active = bool(enabled) and bool(merged_entries)
    theme = str(incoming.get("theme") or "") if combine_mode == "add" and incoming.get("enabled") else ""
    summary = (
        f"{apply_mode}; pools={len(merged_pool_names)}; compositions={len(merged_entries)}"
        if active
        else "disabled or empty"
    )
    return json.dumps(
        {
            "enabled": active,
            "apply_mode": apply_mode,
            "pool_names": merged_pool_names,
            "composition_entries": merged_entries,
            "summary": summary,
            "theme": theme,
        },
        ensure_ascii=True,
        sort_keys=True,
    )


def parse_composition_config(composition_config: str | dict[str, Any] | None) -> dict[str, Any]:
    if not composition_config:
        return {"enabled": False, "apply_mode": "replace", "pool_names": [], "composition_entries": [], "theme": ""}
    if isinstance(composition_config, dict):
        raw = dict(composition_config)
    else:
        try:
            raw = json.loads(str(composition_config))
        except json.JSONDecodeError as exc:
            raise ValueError(f"Invalid composition_config JSON: {exc}") from exc
    if not isinstance(raw, dict):
        raise ValueError("composition_config must be a JSON object")
    entries = _list_from(raw.get("composition_entries"))
    if not entries and raw.get("pool_names"):
        entries = composition_entries_for_pool_names([str(name) for name in _list_from(raw.get("pool_names"))])
    return {
        "enabled": bool(raw.get("enabled")) and bool(entries),
        "apply_mode": str(raw.get("apply_mode") or "replace") if str(raw.get("apply_mode") or "replace") in ("replace", "add") else "replace",
        "pool_names": [str(name) for name in _list_from(raw.get("pool_names")) if str(name).strip()],
        "composition_entries": entries,
        "summary": str(raw.get("summary") or ""),
        "theme": str(raw.get("theme") or ""),
    }


def composition_config_active(composition_config: dict[str, Any]) -> bool:
    return bool(composition_config.get("enabled")) and bool(composition_config.get("composition_entries"))


def build_thematic_location_json(
    enabled: bool = True,
    combine_mode: str = "replace",
    theme: str = "semi_public_affair",
    custom_locations: str = "",
    custom_compositions: str = "",
    location_config: str | dict[str, Any] | None = "",
    composition_config: str | dict[str, Any] | None = "",
) -> tuple[str, str, str]:
    theme_data = THEMATIC_LOCATION_PRESETS.get(str(theme or ""), THEMATIC_LOCATION_PRESETS["semi_public_affair"])
    location_lines = "\n".join(
        f"{entry['slug']}: {entry['prompt']}"
        for entry in theme_data.get("locations", [])
        if isinstance(entry, dict) and entry.get("slug") and entry.get("prompt")
    )
    if custom_locations.strip():
        location_lines = "\n".join(part for part in (location_lines, custom_locations.strip()) if part)
    composition_lines = "\n".join(str(entry) for entry in theme_data.get("compositions", []) if str(entry).strip())
    if custom_compositions.strip():
        composition_lines = "\n".join(part for part in (composition_lines, custom_compositions.strip()) if part)
    resolved_location_config = build_location_pool_json(
        enabled=enabled,
        combine_mode=combine_mode,
        preset="custom_only",
        custom_locations=location_lines,
        location_config=location_config or "",
    )
    resolved_composition_config = build_composition_pool_json(
        enabled=enabled,
        combine_mode=combine_mode,
        preset="custom_only",
        custom_compositions=composition_lines,
        composition_config=composition_config or "",
    )
    location_payload = json.loads(resolved_location_config)
    composition_payload = json.loads(resolved_composition_config)
    location_payload["theme"] = str(theme or "")
    composition_payload["theme"] = str(theme or "")
    themed_scene_entries = []
    for entry in location_payload.get("scene_entries") or []:
        if isinstance(entry, dict):
            themed_entry = dict(entry)
            themed_entry.setdefault("theme", str(theme or ""))
            themed_scene_entries.append(themed_entry)
        else:
            themed_scene_entries.append(entry)
    location_payload["scene_entries"] = themed_scene_entries
    resolved_location_config = json.dumps(location_payload, ensure_ascii=True, sort_keys=True)
    resolved_composition_config = json.dumps(composition_payload, ensure_ascii=True, sort_keys=True)
    location_summary = location_payload.get("summary", "")
    composition_summary = composition_payload.get("summary", "")
    summary = f"{theme}; locations={location_summary}; compositions={composition_summary}"
    return resolved_location_config, resolved_composition_config, summary


_location_pool_names_for_preset = location_pool_names_for_preset
_custom_location_entries = custom_location_entries
_scene_entries_for_pool_names = scene_entries_for_pool_names
_parse_location_config = parse_location_config
_location_config_active = location_config_active
_composition_pool_names_for_preset = composition_pool_names_for_preset
_custom_composition_entries = custom_composition_entries
_composition_entries_for_pool_names = composition_entries_for_pool_names
_parse_composition_config = parse_composition_config
_composition_config_active = composition_config_active
