from __future__ import annotations

import re

COMMON_INPUT_TOOLTIPS = {
    "row_number": "Generation row to use. Changing it advances the deterministic selection without changing the main seed.",
    "start_index": "Metadata/output index offset only. It does not limit category pools or random choices.",
    "seed": "Main seed used when no more specific seed config overrides an axis.",
    "global_seed": "One seed that locks all prompt axes so the same inputs can recreate the same result.",
    "base_seed": "Base seed used by Seed Locker before applying a selected reroll axis.",
    "reroll_seed": "Seed for the selected reroll axis. Use -1 to derive it from the base seed.",
    "category": "Main category source. auto_weighted is legacy random; auto_full mixes legacy random with JSON categories including hardcore.",
    "subcategory": "Specific subcategory, or random to choose within the selected category.",
    "category_config": "Category/subcategory config from SxCP Category Preset.",
    "cast_config": "Cast size config from SxCP Cast Control.",
    "generation_profile": "General style/intensity profile from SxCP Generation Profile.",
    "filter_config": "Ethnicity/body filter config. Ethnicity List can feed this too.",
    "ethnicity_list": "Optional ethnicity pool. When connected, it overrides the slot or generator ethnicity picker.",
    "seed_config": "Per-axis seed config. Use Global Seed for full reproducibility, Seed Locker to reroll one axis, or Seed Control for manual axis seeds.",
    "layer": "Scene layer affected by this side node. all applies to every compatible scene node that receives the options.",
    "seed_mode": "follow_global uses the scene seed, fixed uses the seed field, random resolves a fresh seed at queue time, disabled does nothing.",
    "row_behavior": "same_for_all_rows keeps the option seed as-is; vary_by_row offsets it by row number before writing axis seeds.",
    "reroll_axis": "Specific generator axis group to reroll. none uses the default axes for the selected scene layer.",
    "camera_config": "Camera config consumed only by nodes/options set to from_camera_config.",
    "location_config": "Location config from SxCP Location Pool. It can replace or add to the category scene pool.",
    "composition_config": "Composition config from SxCP Composition Pool or Location Theme. It can replace or add framing options.",
    "style_config": "Visual style config from SxCP Style Pool. It controls realistic/photo/comic rendering separately from category, action, and pose logic.",
    "softcore_camera_config": "Camera config used only for the softcore Insta/OF prompt. Falls back to camera_config if empty.",
    "hardcore_camera_config": "Camera config used only for the hardcore Insta/OF prompt. Falls back to camera_config if empty.",
    "character_profile": "Saved or loaded single-character profile. Character slots override this for configured casts.",
    "character_cast": "Chain slot output into the next slot, then into the generator. The first enabled woman/man in chain order becomes A, then B, and so on.",
    "character_slot": "Single slot payload for saving/loading profiles or debugging one character.",
    "hardcore_position_config": "Hardcore action/position config. Chain Position Pool into Action Filter, then into the generator.",
    "custom_locations": "One custom location per line. Use plain text, or slug: location text.",
    "custom_compositions": "One custom composition/framing phrase per line.",
    "custom_style": "Manual visual style phrase. Use this when the preset list is not specific enough.",
    "custom_positive_suffix": "Manual style/quality suffix merged with the selected style preset.",
    "custom_negative": "Negative style terms added by the style pool.",
    "theme": "Matched location and composition theme, useful when the place needs compatible framing.",
    "metadata_json": "Structured metadata from an SxCP generator. Prefer this over raw prompt text for formatters and profile save.",
    "scene": "Structured v2 scene context. Chain Scene nodes in order, then connect to Scene Output or Scene Pair Output.",
    "softcore_scene": "Softcore branch scene from Scene Branch Pair, optionally refined by Softcore Branch Options.",
    "hardcore_scene": "Hardcore branch scene from Scene Branch Pair, optionally refined by Hardcore Branch Options.",
    "options": "Incoming options of the same type. Chain option nodes with combine_mode=add when multiple side knobs should contribute.",
    "seed_options": "Scene layer seed options. Connect Scene Layer Seed Options to reroll one layer without changing the whole scene.",
    "cast_options": "Optional cast side-node settings that override the Cast node widgets only when connected.",
    "character_options": "Optional character side-node settings that override descriptor, presence, and expression controls.",
    "wardrobe_options": "Optional wardrobe side-node settings for subject-specific clothing, nudity state, and wardrobe prompt text.",
    "location_options": "Optional location layout settings such as foreground anchors, midground, repetition, and public/private context.",
    "set_options": "Optional set-dressing settings for props, repeated background, foreground anchors, and sensory details.",
    "blocking_options": "Optional blocking settings for subject placement, orientation, depth plane, and exact body geography.",
    "action_options": "Optional action settings for scene kind, action family, category preset, and manual action text.",
    "performance_options": "Optional performance settings for expression, gaze, hands, body tension, and actor notes.",
    "camera_options": "Optional camera side-node settings that describe camera source and freeform camera text.",
    "composition_options": "Optional composition side-node settings for readability target, crop, occlusion, and framing text.",
    "lighting_options": "Optional lighting side-node settings for source, softness, contrast, color, and time of day.",
    "branch_options": "Optional branch settings that apply to softcore, hardcore, or both Insta/OF branches.",
    "target_formatter": "Intended downstream formatter target. The scene stores this as metadata; use formatter nodes for final rewriting.",
    "category_preset": "Category preset this scene should render through when no explicit category config overrides it.",
    "central_subject": "Who should be visually central in this scene metadata.",
    "pov_participant": "Optional participant treated as the first-person viewer in later character/camera logic.",
    "subject_label": "Character label affected by this layer. all applies the layer to every matching character slot.",
    "wardrobe_prompt": "Optional wardrobe/set note carried as scene metadata and compatibility extra prompt text.",
    "custom_location": "Exact location text for this scene. One line or JSON entry is enough.",
    "location_note": "Additional location wording merged into the location pool entry.",
    "foreground_anchors": "Objects or surfaces that should stay near the camera or lower frame.",
    "midground_layer": "Readable middle-distance scene elements between the subject and background.",
    "background_repetition": "Repeated environmental structure that helps the model keep a location coherent across rerolls.",
    "visibility_level": "How visible or hidden the scene should feel inside the location.",
    "public_level": "Private, semi-public, or public context for the location layer.",
    "repeated_background": "Repeating background structure such as desks, doors, shelves, pillars, or windows.",
    "props": "Scene props or set dressing objects that make the location readable.",
    "sensory_details": "Small material/light/surface details that make the set dressing feel specific.",
    "set_prompt": "Freeform set-dressing sentence appended to the scene layer.",
    "blocking_mode": "Broad body-placement mode. custom lets custom_blocking carry the exact placement.",
    "subject_placement": "Where the subject or cast sits in the space: foreground, near desk edge, on bed, in aisle, etc.",
    "body_relation": "Spatial relationship between participants, separate from the action itself.",
    "body_orientation": "Front, side, back, three-quarter, or POV-facing body orientation.",
    "depth_plane": "Whether the subjects sit in foreground, midground, background, or a layered composition.",
    "distance_note": "Extra spatial distance wording, such as close together, across the table, or partly hidden behind a shelf.",
    "custom_blocking": "Exact blocking/positioning sentence for the scene layer.",
    "scene_kind": "Regular, softcore, or hardcore intent for this action layer.",
    "action_family": "Broad action family such as softcore tease, oral, penetration, climax, group, or custom.",
    "action_prompt": "Action text stored separately from blocking and camera. Use position pools for hardcore randomization when possible.",
    "gaze": "Where the character looks: camera, partner, down, away, over shoulder, or eyes closed.",
    "hand_placement": "What hands are doing: relaxed, on body, on partner, holding camera, pulling clothing, or braced.",
    "body_tension": "Body performance cue: relaxed, posed, arched, braced, or active motion.",
    "performance_prompt": "Expression, gaze, hand, and body-performance note stored separately from the action.",
    "camera_source": "Where camera text comes from conceptually: config, qwen orbit, POV, phone, external, or manual.",
    "preserve_location_layout": "Keep location layout wording compatible with the camera instead of letting camera text replace the space.",
    "camera_prompt": "Optional freeform camera note kept as scene metadata. Camera config still controls existing formatter behavior.",
    "custom_composition": "Exact composition/framing entry to add to the composition pool.",
    "readability_target": "What the composition should keep most readable: face, body, action, room, anchor objects, or contact points.",
    "crop": "Composition crop intent such as full body, three-quarter, close-up, or extreme close-up.",
    "occlusion": "How much foreground or hidden-sightline occlusion the composition should allow.",
    "composition_prompt": "Additional composition wording merged into the composition layer.",
    "lighting_source": "Main light source family for the scene.",
    "lighting_softness": "Softness of the light: soft, balanced, or hard.",
    "lighting_contrast": "Overall contrast level for the lighting layer.",
    "color_temperature": "Warm, neutral, cool, or mixed color temperature.",
    "time_of_day": "Optional time-of-day lighting context.",
    "custom_lighting": "Exact lighting sentence for the scene layer.",
    "branch_target": "Whether branch options affect both Insta/OF branches, softcore only, or hardcore only.",
    "continuity": "How branch outputs share cast/location setup between softcore and hardcore scenes.",
    "platform_style": "Instagram/OnlyFans styling bias for Scene Pair Output.",
    "softcore_cast": "Whether the softcore branch uses a solo creator or the same cast as the hardcore branch.",
    "hardcore_cast": "Hardcore branch cast preset or explicit count mode.",
    "softcore_level": "Softcore exposure/style level for Scene Pair Output.",
    "hardcore_level": "Hardcore intensity level for Scene Pair Output.",
    "softcore_camera_mode": "Softcore branch camera mode, or from_camera_config to use the connected scene camera.",
    "hardcore_camera_mode": "Hardcore branch camera mode, or from_camera_config to use the connected scene camera.",
    "hardcore_clothing_continuity": "How wardrobe is rendered in the hardcore branch. explicit_nude avoids clothing-token conflicts.",
    "hardcore_detail_density": "How much explicit action detail the current formatter route keeps for the hardcore branch.",
    "source_text": "Raw prompt, caption, or metadata JSON depending on input_hint.",
    "source_text_input": "Optional linked raw prompt/caption input. When connected, it overrides the source_text widget.",
    "input_hint": "Tells the node how to interpret source_text. auto tries metadata first.",
    "target": "For dual prompts, choose which side to output as the main Krea prompt.",
    "detail_level": "Controls how much detail the rewriter keeps. concise is shorter, dense keeps more clauses.",
    "style_mode": "How strongly the formatter rewrites visual style terms.",
    "preserve_trigger": "Keep the trigger token in the formatted prompt instead of stripping it.",
    "negative_prompt": "Negative prompt text to pass through or merge with generated negatives.",
    "extra_positive": "Extra positive text appended after the generated prompt.",
    "extra_negative": "Extra negative text appended after the generated negative prompt.",
    "trigger": "Training or style trigger token.",
    "prepend_trigger_to_prompt": "If enabled, put the trigger token at the start of generated prompts.",
    "bucket_index": "0 picks a random bucket. 1+ picks that position inside the selected orientation pool.",
    "megapixels": "Approximate megapixel count for the selected bucket.",
    "enabled": "Enable this node's effect while keeping it wired in the graph.",
    "combine_mode": "replace starts a new pool/config; add merges selected values into the incoming config.",
    "manual": "Manual character details config from Manual Details. Non-empty fields override generated slot details.",
    "characteristics": "Chainable character pool for controlled randomness such as age, body, eyes, and clothing.",
    "hair_config": "Chainable hair pool. Combine length, color, and style nodes before the character slot.",
    "summary": "Human-readable description of the config produced by this node.",
    "status": "Operation result or warning text.",
    "profile_name": "Name of the profile to save, load, rename, or delete.",
    "manual_profile_name": "Free-text profile name used when profile_name is set to manual.",
    "fallback_profile_json": "Profile JSON to use when a named profile cannot be loaded.",
    "rename_to": "New profile name used only when rename_now is enabled.",
    "save_now": "Writes the profile to disk only when enabled. Keep off while adjusting fields.",
    "delete_now": "Deletes the selected profile when enabled.",
    "rename_now": "Renames the selected profile when enabled.",
    "source": "Where the save node reads character data from.",
    "subject_type": "Character type for this slot or saved profile.",
    "label": "Character label. auto_chain assigns the next Woman/Man label based on incoming cast order.",
    "slot_seed": "Per-character seed for age/body/hair/eyes random picks. Use -1 to derive from the generator person seed.",
    "age": "Age choice for this slot. Use Age Range node for a custom random age pool.",
    "manual_age": "Exact age phrase override, for example '32-year-old adult'.",
    "ethnicity": "Ethnicity choice for this slot. A connected Ethnicity List overrides this picker.",
    "figure": "General figure bias for generated body descriptors.",
    "figure_bias": "Woman-slot figure bias. Body pool can give more precise body choices.",
    "women_count": "Number of women in the generated cast when no Insta/OF preset overrides it.",
    "men_count": "Number of men in the generated cast when no Insta/OF preset overrides it.",
    "hardcore_women_count": "Number of women in the hardcore cast when hardcore_cast is use_counts.",
    "hardcore_men_count": "Number of men in the hardcore cast when hardcore_cast is use_counts.",
    "body": "Body choice for this slot. A Body Pool node can replace the random list.",
    "manual_body": "Exact body phrase override.",
    "body_phrase": "Full custom body wording. Use only when the body picker is not specific enough.",
    "skin": "Manual skin/complexion phrase.",
    "hair": "Manual hair phrase. Hair config nodes are better for controlled random choices.",
    "eyes": "Manual eye description.",
    "descriptor_detail": "How detailed this character's descriptor should be. Men usually work better compact.",
    "expression_enabled": "Master expression toggle for this generator or character.",
    "expression_intensity": "Expression intensity from 0 to 1. On the direct builder, -1 randomizes per row; on slots, -1 inherits the generator setting.",
    "expression_intensity_mode": "For Generation Profile, choose profile_default, random, or fixed value from expression_intensity.",
    "softcore_expression_intensity": "Optional expression intensity override for this character in softcore prompts. -1 inherits.",
    "hardcore_expression_intensity": "Optional expression intensity override for this character in hardcore prompts. -1 inherits.",
    "presence_mode": "Controls whether the character is visible or acts as the male POV participant.",
    "softcore_outfit": "Manual softcore outfit text for this character. Prefer Character Clothing for reusable outfit pools.",
    "hardcore_clothing": "Manual hardcore exposure text for this character. Use explicit nude states when you do not want clothing words repeated.",
    "wardrobe_state": "High-level clothing/body-exposure state. explicit_nude avoids conflicting outfit text in hardcore prompts.",
    "accessories": "Accessories that can remain visible without forcing full outfit wording.",
    "avoid_clothing_when_nude": "When nude states are selected, avoid reintroducing clothing words that make the image model dress the subject.",
    "custom_softcore_outfits": "One custom softcore outfit per line. Used when softcore_source is custom.",
    "custom_hardcore_clothing": "One custom hardcore clothing/body exposure state per line.",
    "condition": "Loop condition. When false, the loop stops and passes current values through.",
    "total": "Total number of loop iterations.",
    "schedule": "Optional loop index schedule. Connect a list or text like 1,3,5 or 2-6; omitted runs 1 through total.",
    "collection": "Existing accumulated value or batch.",
    "value": "Value to append, store, or pass through.",
    "store_key": "Accumulator memory key. Leave blank for node-local storage, or use the same text to share one store across nodes.",
    "store_key_input": "Connect SxCP Accumulator store_key output here so preview/delete/save targets the same store and keeps a graph dependency.",
    "action": "Accumulator operation: append, replace, clear, read, or append a variant.",
    "max_items": "Maximum stored entries kept in this accumulator.",
    "image_batch_mode": "How image entries are batched when dimensions differ.",
    "skip_empty": "Ignore empty inputs instead of adding blank entries.",
    "image": "Image to store in the accumulator.",
    "entry_id": "Stable ID used for replace_by_entry_id or grouping variants.",
    "entry_tag": "Optional suffix added to entry_id.",
    "preview_limit": "Maximum number of accumulator images to show in the preview panel.",
    "view_mode": "Accumulator Preview layout: grid shows many images, carousel shows one large image at a time.",
    "zoom_level": "Accumulator Preview image scale. Higher values make grid thumbnails or carousel image area larger.",
    "carousel_index": "1-based image position shown in carousel mode. The previous/next buttons update this value.",
    "delete_action": "Optional execution-time delete operation. JS buttons can delete interactively without setting this.",
    "delete_entry_id": "Entry id to delete when delete_action is delete_entry_id.",
    "delete_index": "1-based entry index to delete when delete_action is delete_index. 0 disables it.",
    "save_batch": "When enabled, save all current accumulator images during node execution once finished is true.",
    "finished": "Gate for saving. Outside a loop, leave true; inside a loop, wire a final-iteration signal.",
    "save_path": "Folder to save the accumulator batch. Relative paths are inside ComfyUI output; absolute paths are used directly.",
    "filename_prefix": "Filename prefix for saved accumulator images.",
    "clear_after_save": "Clear the accumulator store after a successful batch save.",
    "preview_text": "Serialized persistent text preview. It is updated after execution and saved with the workflow.",
    "preview_format": "How to convert an arbitrary input to preview text.",
    "max_chars": "Maximum stored preview characters. 0 disables truncation.",
    "mode": "Switch direction: pick_input selects one input to value, route_output sends route_value to one output.",
    "index": "Index used by SxCP Index Switch. For Loop Start outputs one_based indexes by default.",
    "index_base": "one_based means index 1 selects input_1. zero_based means index 0 selects input_1.",
    "missing_behavior": "What to do when the requested switch input is not connected: use fallback, output none, clamp, or wrap.",
    "fallback": "Optional value used by SxCP Index Switch when the requested input is missing and missing_behavior is fallback.",
    "route_value": "Value routed to output_N when mode is route_output.",
    "clothing": "Built-in clothing density for legacy direct generation. random picks full/minimal from the seeded row.",
    "poses": "Built-in pose pool for legacy direct generation. random picks standard/evocative from the seeded row.",
    "backside_bias": "Legacy bias toward rear/backside poses where that category supports it.",
    "minimal_clothing_ratio": "Legacy weighted ratio override. -1 keeps the category/profile default.",
    "standard_pose_ratio": "Legacy weighted ratio override. -1 keeps the category/profile default.",
    "profile": "Generation profile preset for broad style, clothing, pose, and expression defaults.",
    "clothing_override": "Override the profile clothing setting, or leave profile_default.",
    "poses_override": "Override the profile pose setting, or leave profile_default.",
    "trigger_policy": "Controls whether the profile prepends the trigger token.",
    "cast_mode": "Preset cast shape. Custom counts are used when the preset allows them.",
    "women_weights": "Comma-separated count weights. First value maps to women_start_count, second to +1, and so on.",
    "men_weights": "Comma-separated count weights. First value maps to men_start_count, second to +1, and so on.",
    "women_start_count": "Woman count represented by the first women_weights value.",
    "men_start_count": "Man count represented by the first men_weights value.",
    "empty_behavior": "What to do if the weighted pick selects zero women and zero men.",
    "preset": "Category preset for common workflow lanes.",
    "camera_mode": "Camera style preset.",
    "shot_size": "How much of the body/frame should be visible.",
    "angle": "Camera angle relative to the subject.",
    "lens": "Lens wording to include in the prompt.",
    "distance": "Camera distance wording.",
    "orientation": "Horizontal/vertical framing wording.",
    "phone_visibility": "Whether the prompt mentions a visible/hidden phone.",
    "priority": "How strictly the prompt should enforce the camera wording.",
    "camera_detail": "off omits camera text, compact keeps one line, full emits detailed camera wording.",
    "subject_focus": "Optional camera focus phrase, such as face/body/contact emphasis.",
    "strict_excludes": "When enabled, only selected ethnicity groups are used. When off, selections act more like soft includes.",
    "min_age": "Minimum adult age in this custom age pool.",
    "max_age": "Maximum adult age in this custom age pool.",
    "softcore_source": "Softcore outfit source for this character. custom reads custom_softcore_outfits.",
    "hardcore_state": "Hardcore clothing/body exposure state for this character.",
    "softcore_expression_enabled": "Enable expression text in the softcore prompt.",
    "hardcore_expression_enabled": "Enable expression text in the hardcore prompt.",
    "flow": "Loop flow-control socket. Wire from the matching loop start node.",
    "collection_mode": "How the loop end collects per-iteration values.",
    "skip_none": "Do not add empty values to the collection.",
    "collected": "Current accumulated value carried through the loop.",
    "collect_value": "Value captured from the current loop iteration.",
    "a": "First integer/boolean helper input.",
    "b": "Second integer/boolean helper input.",
}

NODE_INPUT_TOOLTIPS = {
    "SxCPGlobalSeed": {
        "global_seed": "Master reproducibility seed. Connect seed to generator seed and seed_config to seed_config so random choices can be recreated exactly.",
    },
    "SxCPSeedControl": {
        "category_seed_mode": "auto/follow_main follows the main seed; fixed uses category_seed; random rerolls this axis at queue time while the field value stays unchanged.",
        "subcategory_seed_mode": "Controls which subcategory is selected. Change this to switch oral vs penetration when both are allowed.",
        "content_seed_mode": "Controls item/outfit content for non-pose categories.",
        "clothing_seed_mode": "Controls clothing/outfit selection separately from content item selection.",
        "clothing_seed": "Seed used when clothing_seed_mode is fixed or auto with a non-negative value.",
        "person_seed_mode": "Controls generated character appearance unless a slot seed overrides it.",
        "scene_seed_mode": "Controls location/scene selection.",
        "pose_seed_mode": "Controls pose/item selection for pose categories, including hardcore positions.",
        "role_seed_mode": "Controls role assignment and secondary action details.",
        "expression_seed_mode": "Controls selected expression text.",
        "composition_seed_mode": "Controls framing/composition text.",
    },
    "SxCPSeedLocker": {
        "base_seed": "Master seed for the locked result. Use the same value as the generator seed for simplest reproduction.",
        "reroll_axis": "Choose the one axis to change while the rest stays locked. Use clothing for outfit-only rerolls, pose for sexual pose, scene for location, person for appearance.",
        "reroll_seed": "Seed for the selected axis only. Leave -1 to derive a stable reroll from base_seed.",
    },
    "SxCPCastBias": {
        "seed": "Fixed cast-bias seed. Use -1 for a fresh cast each queue, or connect Global Seed/Seed Locker through seed_config.",
        "seed_config": "Optional seed config. The category seed controls weighted cast selection.",
        "women_weights": "Example with women_start_count=1: 0.6,0.25,0.1 means 60% one woman, 25% two women, 10% three women.",
        "men_weights": "Example with men_start_count=0: 0.5,0.35,0.1 means 50% no man, 35% one man, 10% two men.",
        "empty_behavior": "Prevents accidental empty casts when both weighted pools pick zero.",
    },
    "SxCPSDXLBucketSize": {
        "orientation": "Bucket orientation filter. any uses the full table; portrait/square/landscape restrict random selection.",
        "seed": "Fixed bucket seed. Use -1 for a fresh random bucket each queue, or connect Global Seed for reproducible sizes.",
        "row_number": "Deterministic row offset for the bucket. With a fixed seed, changing this advances the bucket choice.",
        "bucket_index": "0=random. 1+ selects that bucket position inside the selected orientation pool and ignores seed.",
        "seed_config": "Optional seed config. The composition seed controls bucket choice, so Seed Locker can keep sizes fixed while rerolling pose/person.",
    },
    "SxCPKrea2ResolutionSelector": {
        "megapixels": "Target megapixel preset. If it cannot fit the aspect ratio under the 2K Krea2 Turbo limit, the node clamps to the maximum valid size.",
        "aspect_ratio": "Krea API ratios are listed first; local-only helper ratios like 8:9 are included after them.",
    },
    "SxCPCameraControl": {
        "camera_mode": "Camera style preset. Use from_camera_config in Insta/OF options to consume this.",
        "priority": "locked makes the camera wording strict; soft_hint allows the model more freedom.",
        "camera_detail": "off omits camera text, compact keeps one short line, full emits detailed camera constraints.",
        "phone_visibility": "Use phone_hidden or suppress_phone_visibility when you do not want 'phone hidden' text in prompts.",
    },
    "SxCPCameraOrbitControl": {
        "enabled": "When false, outputs an empty camera config so downstream nodes fall back to their own camera settings.",
        "horizontal_angle": "Orbit angle in degrees. 0=front, 90=right side, 180=back, 270=left side.",
        "vertical_angle": "Camera elevation. Negative looks up, positive looks down.",
        "zoom": "Maps to distance/framing when framing is from_zoom.",
        "framing": "How zoom should be translated into shot size/distance wording.",
        "include_degrees": "Include numeric degree wording in addition to human camera direction.",
    },
    "SxCPQwenCameraTranslator": {
        "qwen_prompt": "Camera prompt from Qwen MultiAngle, for example '<sks> front-right quarter view eye-level shot medium shot'.",
        "camera_info": "Optional structured camera_info from Qwen MultiAngle. Used before qwen_prompt when prefer_camera_info is true.",
        "prefer_camera_info": "Use structured camera_info values when available instead of parsing the text prompt.",
        "phone_visibility": "Leave auto when using Qwen/Orbit camera prompts unless you explicitly want phone visibility text.",
        "suppress_phone_visibility": "Avoid adding phone visibility text unless you explicitly set a phone option.",
    },
    "SxCPStylePool": {
        "enabled": "Disable to keep the node wired while preserving category/default style behavior.",
        "combine_mode": "replace overrides category style; add appends this visual style to incoming/category style; disabled emits no style override.",
        "preset": "Visual rendering preset only. It does not select content, pose, exposure, or camera.",
        "style_config": "Optional incoming style config. Use combine_mode=add to chain multiple style nodes.",
        "custom_style": "Manual visual style phrase, for example realistic phone photo or colored-pencil pin-up.",
        "custom_positive_suffix": "Extra rendering/detail sentence added to the prompt when the style is active.",
        "custom_negative": "Negative style terms merged into the generated negative prompt.",
    },
    "SxCPHardcorePositionPool": {
        "family": "Restrict the broad hardcore family. Use any when you want oral and penetration to both be possible.",
        "combine_mode": "replace discards incoming position choices; add merges these choices with the incoming config.",
        "hardcore_position_config": "Optional incoming config. Usually connect previous Position Pool here only when chaining pools.",
    },
    "SxCPKrea2PoseVariant": {
        "variant_key": "Atlas-calibrated Krea2 POV pose variant. Proven variants have fixed-seed evidence in the eval log.",
        "combine_mode": "replace discards incoming position choices; add merges this variant with the incoming position config.",
        "atlas_cue_seed": "Optional cue seed for selecting an explicit catalog prompt_variant_cues set. Use -1 to let the generator pose seed choose.",
        "hardcore_position_config": "Optional incoming hardcore position config. Connect this when layering a variant on an existing pool.",
    },
    "SxCPKrea2POVPenetrationFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVOralFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVOutercourseFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVManualFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVToyFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVClimaxFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVInteractionFilter": {
        "atlas_cue_seed": "Optional cue seed for selecting explicit catalog atlas prompt variants. It is separate from the sampler seed; -1 follows the generator pose seed.",
    },
    "SxCPKrea2POVPromptRestore": {
        "restore_clothing_detail": "Let compatible clothing/body-exposure detail survive a strict Krea2 POV atlas pose lock when the source category has that axis.",
        "restore_face_expression_detail": "Restore compatible face, expression, mouth, and reaction detail as visible prompt detail without changing the atlas pose.",
        "restore_body_touch_detail": "Restore compatible body-contact, hand, touch, and foreplay detail as visible prompt detail while keeping the pose locked.",
        "restore_camera_presentation_detail": "Restore compatible presentation and visibility wording. Camera angle axes stay locked to the atlas pose.",
        "relax_non_pose_axis_conflicts": "Allow restored non-pose axes to pass position-conflict pruning. Position axes remain locked to the selected atlas pose.",
        "hardcore_position_config": "Optional incoming Krea2 POV position config. Use before or after a POV filter to add restored prompt-detail axes.",
    },
    "SxCPKrea2VariantEvidence": {
        "variant_key": "Catalog variant whose fixed-seed eval evidence should be shown.",
        "result": "Filter eval entries by result. accepted is the evidence used for proven variants.",
        "variant_key_in": "Optional connected variant key from SxCP Krea2 Pose Variant. When connected, it overrides the selector.",
    },
    "SxCPHardcoreActionFilter": {
        "focus": "keep_pool preserves/broadens the incoming pool; *_only modes force one action family.",
        "allow_toys": "Allow toy/strap-on wording in hardcore actions.",
        "allow_double": "Allow double-penetration or second-contact wording.",
        "allow_penetration": "Allow vaginal/penetrative sex subcategories.",
        "allow_foreplay": "Allow hardcore teasing/foreplay setup actions such as kissing, caressing, breast/face touching, and undressing.",
        "allow_interaction": "Allow non-act interaction pools such as body worship, clothing transitions, guidance, camera presentation, watching, and aftercare.",
        "allow_manual": "Allow manual stimulation pools such as fingering, clit rubbing, and mutual masturbation.",
        "allow_oral": "Allow oral sex subcategories.",
        "allow_outercourse": "Allow non-penetrative penis-contact acts such as boobjob/titjob, footjob, penis licking, and testicle sucking.",
        "allow_anal": "Allow anal subcategories.",
        "allow_climax": "Allow cumshot/climax aftermath subcategories.",
    },
    "SxCPChoiceBoard": {
        "metadata_json": "Pair metadata from Scene Pair Output or Insta/OF Prompt Pair. The board reads the resolved choices just before prompt output.",
        "lock_choice": "Use one current resolved choice as an override source, such as current location, current composition, current position, or current outfit.",
        "location_override": "Manual exact location replacement. When set, it overrides the lock_choice location.",
        "composition_override": "Manual exact composition replacement. When set, it overrides the lock_choice composition.",
        "hardcore_position_family": "Optional hardcore position family override. auto uses the current metadata when lock_choice is hardcore_position_current.",
        "hardcore_position_key": "Optional exact hardcore position key override. auto uses the current metadata when lock_choice is hardcore_position_current.",
        "wardrobe_subject": "Character slot target for outfit/clothing overrides.",
        "softcore_outfit_override": "Manual softcore outfit for the selected character slot. Empty keeps the current slot value unless lock_choice uses the current outfit.",
        "hardcore_clothing_override": "Manual hardcore clothing/body exposure text for the selected character slot.",
    },
    "SxCPInstaOFOptions": {
        "softcore_cast": "solo keeps softcore focused on Woman A; same_as_hardcore includes the same cast as the hardcore prompt.",
        "hardcore_cast": "use_counts reads hardcore_women_count/hardcore_men_count; presets set the counts automatically.",
        "softcore_level": "Controls the soft prompt exposure/outfit level.",
        "hardcore_level": "Controls how explicit the hardcore prompt style is.",
        "platform_style": "Instagram/OnlyFans styling bias for the dual prompt pair.",
        "continuity": "Whether the softcore and hardcore prompts share the room/creator setup.",
        "hardcore_clothing_continuity": "How clothing carries from softcore to hardcore. explicit_nude avoids outfit references so clothing tokens do not fight nudity.",
        "softcore_camera_mode": "Camera mode for the softcore prompt, or from_camera_config.",
        "hardcore_camera_mode": "Camera mode for the hardcore prompt. same_as_softcore reuses the softcore setting.",
        "camera_detail": "Global camera verbosity for the pair unless a camera config overrides it.",
        "hardcore_detail_density": "How dense the hardcore action sentence should be in the Krea formatter.",
    },
    "SxCPInstaOFPromptPair": {
        "options_json": "Options from SxCP Insta/OF Options. If empty, defaults are used.",
        "ethnicity": "Fallback ethnicity when no filter/ethnicity list or character slots are connected.",
        "figure": "Fallback figure bias when no character slot overrides it.",
    },
    "SxCPPromptBuilderFromConfigs": {
        "seed": "Main seed. Connect Seed Config for per-axis control.",
    },
    "SxCPCharacterSlot": {
        "subject_type": "Choose whether this slot creates a woman or man. Man is required for POV presence.",
        "label": "auto_chain uses the next free label for that subject type based on incoming cast order.",
        "character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
        "presence_mode": "POV only has an effect for men; it makes the man implied by camera/body cues instead of fully described.",
        "characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
        "hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
    },
    "SxCPWomanSlot": {
        "label": "auto_chain uses the next free Woman label based on incoming cast order.",
        "character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
        "figure_bias": "Broad woman body bias. Body Pool or manual body wording can narrow the actual phrase.",
        "characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
        "hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
    },
    "SxCPManSlot": {
        "label": "auto_chain uses the next free Man label based on incoming cast order.",
        "character_cast": "Optional incoming cast from the previous slot. Output this node's character_cast to the next slot or final generator.",
        "presence_mode": "visible describes the man normally; pov makes him the first-person viewer and suppresses most man descriptor text.",
        "descriptor_detail": "compact or minimal usually works better for non-primary men; full makes the man as detailed as the creator.",
        "characteristics": "Optional controlled-random pool from age/body/eye/clothing nodes. Connected pools override the matching random choices.",
        "hair_config": "Optional controlled-random hair pool. Chain Hair Length, Hair Color, and Hair Style before this slot.",
    },
    "SxCPCharacterClothing": {
        "softcore_source": "Built-in softcore outfit pool. custom reads custom_softcore_outfits; no_change leaves the current pool untouched.",
        "hardcore_state": "Hardcore exposure pool. explicit_nude avoids outfit references; partially_removed can intentionally keep clothing words.",
        "characteristics": "Incoming characteristic pool to extend or replace with clothing choices.",
    },
    "SxCPCharacterProfileSave": {
        "profile_name": "Profile filename stem. Saving requires save_now=true.",
        "metadata_json": "Use generator metadata to save the currently generated character without regenerating it.",
        "character_slot": "Use this when saving a configured slot directly.",
    },
    "SxCPCharacterProfileLoad": {
        "enabled": "When false, outputs an empty profile and leaves downstream generation unchanged.",
        "override_age": "Optional loaded-profile override. Empty keeps the profile value.",
        "override_body": "Optional body override. Empty keeps the profile value.",
        "override_descriptor_detail": "Override descriptor verbosity while keeping the rest of the loaded profile.",
    },
    "SxCPKrea2Formatter": {
        "metadata_json": "Best input for Krea2 formatting because it preserves cast, camera, and hardcore action metadata.",
        "preserve_trigger": "Reminder: Krea2 formatting is intended to remove training/style triggers. Leave false unless you intentionally want a raw text trigger preserved.",
        "source_text": "Raw prompt fallback. Known trigger tokens are stripped by default for Krea2.",
    },
    "SxCPSDXLFormatter": {
        "metadata_json": "Best input for SDXL tag formatting because it preserves cast, camera, outfit, and explicit action metadata.",
        "formatter_profile": "High-level formatter defaults. manual_controls keeps style_preset and quality_preset authoritative.",
        "style_preset": "Positive style anchor preset. flat_vector_pony matches the old SDXL tag style.",
        "quality_preset": "Quality/score tag tail for SDXL or Pony-style checkpoints.",
        "custom_style": "Optional replacement for the style preset. Leave empty to use style_preset.",
        "custom_quality": "Optional replacement for the quality preset. Leave empty to use quality_preset.",
        "nude_weight": "Weight used when explicit nude/body exposure tags are inferred.",
    },
    "SxCPCaptionNaturalizer": {
        "metadata_json": "Best input for training captions because it preserves structured generator details.",
        "caption_profile": "Preset behavior for the caption rewrite. manual_controls keeps detail/style/include-trigger widgets authoritative.",
        "style_policy": "drop_style_tail removes generation/style boilerplate; keep_style_terms preserves more of it.",
        "include_trigger": "Keep this true for LoRA/training captions so the trigger token is learned.",
    },
    "SxCPForLoopStart": {
        "schedule": "Optional 1-based indexes to run. Accepts lists, JSON arrays, comma-separated text, and ranges like 2-6.",
        "index": "Output loop index. With a schedule, this follows the scheduled 1-based indexes.",
        "collected": "Current accumulated value carried through the loop.",
    },
    "SxCPLoopNextIndex": {
        "current_index": "Current loop index used to choose the next scheduled index.",
        "schedule": "Optional 1-based indexes to run. Omitted advances by one until total.",
    },
    "SxCPLoopAppend": {
        "mode": "auto_batch tries tensor/latent batching first, then falls back to a list.",
    },
    "SxCPAccumulator": {
        "image_batch_mode": "same_size_only keeps incompatible sizes separate; resize_to_first forces one image batch.",
    },
    "SxCPAccumulatorPreview": {
        "store_key": "Use the same key as the Accumulator store. Prefer wiring store_key_input from Accumulator to avoid mismatches.",
        "view_mode": "grid shows all entries together; carousel keeps one large image visible for review.",
        "zoom_level": "Visual preview scale only. It does not resize stored images or saved files.",
        "delete_action": "Execution-time delete/clear. The preview JS buttons can also delete without changing this widget.",
        "save_batch": "Saves all current accumulator images when the workflow executes and finished is true.",
        "clear_after_save": "Clear the in-memory accumulator only after a successful save.",
    },
    "SxCPIndexSwitch": {
        "mode": "pick_input selects input_N as value; route_output sends route_value to output_N.",
        "index": "Selected input/output index. With one_based, index 1 maps to input_1/output_1.",
        "missing_behavior": "Controls missing indexes: fallback uses fallback, clamp/wrap select another slot, none returns empty.",
        "route_value": "Value sent to the selected output_N when mode is route_output.",
    },
}


def _tooltip_for_input(node_name: str, input_name: str) -> str:
    node_tooltips = NODE_INPUT_TOOLTIPS.get(node_name, {})
    if input_name in node_tooltips:
        return node_tooltips[input_name]
    if input_name in COMMON_INPUT_TOOLTIPS:
        return COMMON_INPUT_TOOLTIPS[input_name]
    if input_name.endswith("_seed_mode"):
        axis = input_name[: -len("_seed_mode")]
        return f"How the {axis} seed is resolved: follow the main seed, use the fixed field, or reroll randomly."
    if input_name.endswith("_seed"):
        axis = input_name[: -len("_seed")]
        return f"Fixed {axis} seed value. Used only when the matching seed mode is fixed, or as a fallback for auto modes."
    if input_name.startswith("include_"):
        value = input_name[len("include_") :].replace("_", " ")
        return f"Include {value} in this random pool."
    if input_name.startswith("initial_value"):
        return "Carry value passed into the loop body and returned on the matching output."
    if re.match(r"^input_\d+$", input_name):
        return "Autoscaling switch input. Connect the last visible input to reveal the next one."
    if re.match(r"^output_\d+$", input_name):
        return "Autoscaling routed output. Connect the last visible output to reveal the next one."
    if input_name.startswith("override_"):
        return "Optional loaded-profile override. Leave empty or keep_profile to preserve the profile value."
    return ""


def _copy_input_spec_with_tooltip(input_spec, tooltip: str):
    if not tooltip or not isinstance(input_spec, tuple):
        return input_spec
    if len(input_spec) >= 2 and isinstance(input_spec[1], dict):
        options = dict(input_spec[1])
        options.setdefault("tooltip", tooltip)
        return (input_spec[0], options, *input_spec[2:])
    if len(input_spec) == 1:
        return (input_spec[0], {"tooltip": tooltip})
    return input_spec


def _inject_input_tooltips(input_types: dict, node_name: str) -> dict:
    patched = dict(input_types)
    for group_name in ("required", "optional"):
        group = patched.get(group_name)
        if not isinstance(group, dict):
            continue
        patched_group = {}
        for input_name, input_spec in group.items():
            patched_group[input_name] = _copy_input_spec_with_tooltip(
                input_spec,
                _tooltip_for_input(node_name, input_name),
            )
        patched[group_name] = patched_group
    return patched


def install_input_tooltips(node_classes: dict[str, type]) -> None:
    for node_name, node_class in node_classes.items():
        original = getattr(node_class, "INPUT_TYPES", None)
        if original is None or getattr(node_class, "_sxcp_tooltips_installed", False):
            continue

        def input_types(cls, _original=original, _node_name=node_name):
            return _inject_input_tooltips(_original(), _node_name)

        node_class.INPUT_TYPES = classmethod(input_types)
        node_class._sxcp_tooltips_installed = True
