
from mjr_am_backend.features.geninfo import parser_impl as p


def test_sink_priority_helpers():
    node_v = {"class_type": "SaveVideo", "inputs": {"video": ["1", 0]}}
    node_i = {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}
    assert p._sink_is_video("savevideo") is True
    assert p._sink_is_audio("saveaudio") is True
    assert p._sink_is_image("saveimage") is True
    assert p._sink_is_preview("previewimage") is True
    assert p._sink_group("savevideo") < p._sink_group("saveimage")
    assert p._sink_priority(node_v, "5")[0] == 0
    assert p._sink_images_tiebreak(node_i) == 0
    assert p._sink_node_id_tiebreak("10") == -10


def test_core_scalar_and_link_helpers():
    assert p._clean_model_id("C:/m/model.safetensors") == "model"
    assert p._to_int("12") == 12
    assert p._is_link(["1", 0]) is True
    assert p._resolve_link(["1", 0]) == ("1", 0)
    assert p._node_type({"class_type": "X"}) == "X"
    assert p._inputs({"inputs": {"a": 1}}) == {"a": 1}
    assert p._lower("A") == "a"


def test_prompt_string_helpers():
    assert p._is_numeric_like_text("12, 34") is True
    assert p._has_control_chars("a\x01") is True
    assert p._looks_like_prompt_string("this is prompt text") is True
    assert p._looks_like_prompt_string("12345") is False


def test_walk_passthrough_and_reroute():
    nodes = {
        "1": {"class_type": "Reroute", "inputs": {"x": ["2", 0]}},
        "2": {"class_type": "Reroute", "inputs": {"x": ["3", 0]}},
        "3": {"class_type": "KSampler", "inputs": {}},
    }
    assert p._is_reroute(nodes["1"]) is True
    assert p._walk_passthrough(nodes, ["1", 0]) == "3"


def test_walk_passthrough_resolves_binary_switch_branch():
    nodes = {
        "1": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
        "2": {"class_type": "UNETLoader", "inputs": {"unet_name": "base.safetensors"}},
        "3": {"class_type": "LoraLoaderModelOnly", "inputs": {"lora_name": "boost.safetensors", "model": ["2", 0]}},
        "4": {"class_type": "ComfySwitchNode", "inputs": {"switch": ["1", 0], "on_false": ["2", 0], "on_true": ["3", 0]}},
    }

    assert p._walk_passthrough(nodes, ["4", 0]) == "3"


def test_trace_size_respects_configured_max_hops(monkeypatch):
    nodes = {
        "1": {"class_type": "KSampler", "inputs": {"latent_image": ["2", 0]}},
        "2": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 768}},
    }
    monkeypatch.setattr(p, "DEFAULT_MAX_TRACE_HOPS", 1)
    assert p._trace_size(nodes, ["1", 0], "high") is None
    monkeypatch.setattr(p, "DEFAULT_MAX_TRACE_HOPS", 2)
    size = p._trace_size(nodes, ["1", 0], "high")
    assert size and size["width"] == 512 and size["height"] == 768


def test_field_helpers():
    assert p._field(None, "h", "s") is None
    assert p._field(1, "h", "s")["value"] == 1
    assert p._field_name("n", "h", "s")["name"] == "n"
    assert p._field_size(1, 2, "h", "s")["width"] == 1


def test_pick_sink_inputs_and_find_candidate_sinks():
    n = {"inputs": {"images": ["1", 0], "x": ["2", 0]}}
    assert p._pick_sink_inputs(n) == ["1", 0]

    nodes = {
        "1": {"class_type": "SaveImage", "inputs": {}},
        "2": {"class_type": "CustomSaveVideo", "inputs": {}},
        "3": {"class_type": "Nope", "inputs": {}},
    }
    out = p._find_candidate_sinks(nodes)
    assert "1" in out and "2" in out and "3" not in out


def test_collect_upstream_nodes_bfs():
    nodes = {
        "3": {"inputs": {"a": ["2", 0]}},
        "2": {"inputs": {"a": ["1", 0]}},
        "1": {"inputs": {}},
    }
    dist = p._collect_upstream_nodes(nodes, "3", max_nodes=10, max_depth=10)
    assert dist["3"] == 0 and dist["1"] == 2


def test_sampler_detection_helpers():
    k = {"class_type": "KSampler", "inputs": {"steps": 20, "cfg": 7, "seed": 1}}
    c = {"class_type": "MySampler", "inputs": {"model": ["1", 0]}}
    a = {"class_type": "SamplerCustomAdvanced", "inputs": {"guider": ["1", 0], "sigmas": ["2", 0]}}
    assert p._is_named_sampler_type("ksampler") is True
    assert p._has_core_sampler_signature(k) is True
    assert p._is_custom_sampler(c, "mysampler") is True
    assert p._is_advanced_sampler(a) is True
    assert p._is_sampler(k) is True


def test_loader_and_input_kind_helpers():
    assert p._looks_like_loader_filename("x.png") is True
    assert p._looks_like_loader_filename("a") is False
    assert p._workflow_sink_suffix("saveaudio") == "A"
    assert p._is_image_loader_node_type("loadimage") is True
    assert p._is_video_loader_node_type("loadvideo") is True
    assert p._is_audio_loader_node_type("loadaudio") is True


def test_extract_input_files_and_scan_kinds():
    nodes = {
        "1": {"class_type": "LoadImage", "inputs": {"image": "a.png", "subfolder": "", "type": "input"}},
        "2": {"class_type": "LoadVideo", "inputs": {"video": "b.mp4"}},
        "3": {"class_type": "LoadAudio", "inputs": {"audio": "c.mp3"}},
    }
    files = p._extract_input_files(nodes)
    assert len(files) >= 2

    img, vid, aud = p._scan_graph_input_kinds(nodes)
    assert img is True and vid is True and aud is True


def test_litegraph_normalization_helpers():
    workflow = {
        "nodes": [
            {"id": 1, "type": "TextEncode", "inputs": [{"name": "text", "link": None, "widget": True}], "widgets_values": ["hello world text"]},
            {"id": 2, "type": "KSampler", "inputs": [{"name": "model", "link": 10}], "widgets_values": []},
        ],
        "links": [[10, 1, 0, 2, 0, "MODEL"]],
    }
    link_map = p._build_link_source_map(workflow["links"])
    assert link_map[10] == (1, 0)

    node = p._convert_litegraph_node(workflow["nodes"][0], link_map)
    assert "inputs" in node

    by_id = p._normalize_graph_input(None, workflow)
    assert by_id and "1" in by_id and "2" in by_id


def test_resolve_graph_target_and_metadata_only_result():
    assert p._resolve_graph_target({"1": {}}, None) == {"1": {}}
    assert p._resolve_graph_target(None, {"nodes": []}) == {"nodes": []}
    assert p._resolve_graph_target(None, None) is None

    assert p._geninfo_metadata_only_result({"a": 1}).ok
    assert p._geninfo_metadata_only_result(None).ok


def test_set_field_helpers():
    out = {}
    p._set_named_field(out, "sampler", "Euler", "x")
    p._set_value_field(out, "steps", 20, "x")
    assert out["sampler"]["name"] == "Euler"
    assert out["steps"]["value"] == 20


def test_find_tts_nodes_helpers():
    nodes = {
        "1": {"class_type": "UnifiedTTSTextNode", "inputs": {}},
        "2": {"class_type": "TTSEngine", "inputs": {}},
    }
    t_id, t_node, e_id, e_node = p._find_tts_nodes(nodes)
    assert t_id == "1" and e_id == "2"
    assert p._is_tts_text_node_type("tts_text_node") is True
    assert p._is_tts_engine_node_type("ttsengine") is True


def test_parse_geninfo_from_prompt_guard_paths():
    out1 = p.parse_geninfo_from_prompt(None)
    assert out1.ok

    out2 = p.parse_geninfo_from_prompt({"1": {"class_type": "NoSink", "inputs": {}}})
    assert out2.ok


def test_no_sink_custom_graph_uses_generic_geninfo_fields():
    nodes = {
        "1": {
            "class_type": "MysteryTextToImage",
            "inputs": {
                "prompt": "cinematic castle above a lake at sunrise",
                "negative_prompt": "low quality, blurry",
                "seed": 123,
                "steps": 18,
                "cfg_scale": 5.5,
                "model_name": "custom_model.safetensors",
                "sampler_name": "euler",
                "scheduler": "normal",
            },
        }
    }

    res = p.parse_geninfo_from_prompt(nodes)

    assert res.ok and isinstance(res.data, dict)
    assert res.data["positive"]["value"] == "cinematic castle above a lake at sunrise"
    assert res.data["negative"]["value"] == "low quality, blurry"
    assert res.data["seed"]["value"] == 123
    assert res.data["steps"]["value"] == 18
    assert res.data["cfg"]["value"] == 5.5
    assert res.data["checkpoint"]["name"] == "custom_model"
    assert res.data["generic_fields"]["raw_summary"]["node_count"] == 1


def test_parse_geninfo_uses_majoor_override_from_workflow_metadata():
    workflow = {
        "majoor_geninfo": {
            "mode": "override",
            "prompt": "forced positive prompt",
            "negative_prompt": "forced negative prompt",
            "seed": "12345",
            "steps": 30,
            "cfg": 7.0,
            "sampler": "DPM++ 2M",
            "scheduler": "Karras",
            "model": "model_name.safetensors",
            "vae": "vae_name.safetensors",
            "clip": "clip_name.safetensors",
            "loras": [{"name": "lora1.safetensors", "strength": 0.8}],
            "workflow_notes": "forced notes",
            "custom_info": [{"title": "Workflow Notes", "content": "Used high-res fix", "color": "#4CAF50"}],
        },
        "nodes": [],
    }
    prompt = {
        "1": {"class_type": "KSampler", "inputs": {"seed": 1, "steps": 1, "cfg": 1}},
        "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
    }

    res = p.parse_geninfo_from_prompt(prompt, workflow=workflow)

    assert res.ok and isinstance(res.data, dict)
    assert res.data["engine"]["parser_version"] == "geninfo-override-v1"
    assert res.data["positive"]["value"] == "forced positive prompt"
    assert res.data["negative"]["value"] == "forced negative prompt"
    assert res.data["seed"]["value"] == 12345
    assert res.data["checkpoint"]["name"] == "model_name"
    assert res.data["vae"]["name"] == "vae_name"
    assert res.data["clip"]["name"] == "clip_name"
    assert res.data["loras"][0]["name"] == "lora1"
    assert res.data["custom_info"][0]["color"] == "#4CAF50"


def test_parse_geninfo_partial_override_falls_back_to_workflow_fields():
    workflow = {
        "majoor_geninfo": {"mode": "override", "seed": 999},
        "nodes": [],
    }
    prompt = {
        "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "base.safetensors"}},
        "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "normal positive prompt", "clip": ["1", 1]}},
        "3": {"class_type": "CLIPTextEncode", "inputs": {"text": "normal negative prompt", "clip": ["1", 1]}},
        "4": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 768}},
        "5": {
            "class_type": "KSampler",
            "inputs": {
                "model": ["1", 0],
                "positive": ["2", 0],
                "negative": ["3", 0],
                "latent_image": ["4", 0],
                "seed": 123,
                "steps": 20,
                "cfg": 7.5,
                "sampler_name": "euler",
                "scheduler": "normal",
            },
        },
        "6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
    }

    res = p.parse_geninfo_from_prompt(prompt, workflow=workflow)

    assert res.ok and isinstance(res.data, dict)
    assert res.data["engine"]["mode"] == "override"
    assert res.data["positive"]["value"] == "normal positive prompt"
    assert res.data["negative"]["value"] == "normal negative prompt"
    assert res.data["steps"]["value"] == 20
    assert res.data["cfg"]["value"] == 7.5
    assert res.data["sampler"]["name"] == "euler"
    assert res.data["checkpoint"]["name"] == "base"
    assert res.data["seed"]["value"] == 999
    assert res.data["seed"]["source"] == "majoor_geninfo"


def test_generic_geninfo_does_not_override_sampler_trace_fields():
    nodes = {
        "1": {"class_type": "CLIPLoader", "inputs": {"clip_name": "clip.safetensors"}},
        "2": {"class_type": "CLIPTextEncode", "inputs": {"text": "actual positive prompt", "clip": ["1", 0]}},
        "3": {"class_type": "CLIPTextEncode", "inputs": {"text": "actual negative prompt", "clip": ["1", 0]}},
        "4": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512}},
        "5": {
            "class_type": "KSampler",
            "inputs": {
                "prompt": "generic prompt should not win",
                "seed": 111,
                "steps": 20,
                "cfg": 7,
                "sampler_name": "euler",
                "scheduler": "normal",
                "model": ["1", 0],
                "positive": ["2", 0],
                "negative": ["3", 0],
                "latent_image": ["4", 0],
            },
        },
        "6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
    }

    res = p.parse_geninfo_from_prompt(nodes)

    assert res.ok and isinstance(res.data, dict)
    assert res.data["positive"]["value"] == "actual positive prompt"
    assert res.data["positive"]["confidence"] == "high"
    assert res.data["generic_fields"]["source"] == "generic_graph_scan"


def test_tts_widget_and_direct_helpers(monkeypatch):
    out = {}
    p._apply_tts_text_direct_fields(
        out,
        {
            "text": "hello tts",
            "seed": 12,
            "narrator_voice": "Alice",
            "batch_size": 2,
        },
        "src",
    )
    assert out["positive"]["value"] == "hello tts"
    assert out["seed"]["value"] == 12
    assert out["voice"]["name"] == "Alice"

    out2 = {}
    p._apply_tts_text_widget_fallback(out2, ["short", "VoiceA", 42, "a very long prompt text for fallback"], "src")
    assert "positive" in out2 and "seed" in out2

    assert p._first_long_widget_text(["x", "this is long enough text"]) == "this is long enough text"
    assert p._first_nonnegative_int_scalar(["x", -1, 5]) == 5
    assert p._widget_voice_name(["x", "VoiceA"]) == "VoiceA"


def test_tts_narrator_and_engine_helpers(monkeypatch):
    nodes = {"2": {"class_type": "NarratorNode", "inputs": {"voice_name": "NarratorX"}}}
    monkeypatch.setattr(p, "_is_link", lambda v: True)
    monkeypatch.setattr(p, "_walk_passthrough", lambda nodes_by_id, link, max_hops=50: "2")

    out = {}
    p._apply_tts_narrator_link_voice(out, nodes, {"opt_narrator": ["1", 0]})
    assert isinstance(out, dict)

    out2 = {}
    p._apply_tts_engine_direct_fields(
        out2,
        {"model": "C:/m/ckpt.safetensors", "language": "fr", "top_k": 50},
        "engine",
    )
    assert out2["checkpoint"]["name"] == "ckpt"
    assert out2["language"]["value"] == "fr"
    assert out2["top_k"]["value"] == 50

    out3 = {}
    node = {"class_type": "Qwen3TTSEngine", "widgets_values": ["q.safetensors", "cuda", "voiceA", "en", "", 5, 0.9, 0.7, 1.1, 128]}
    p._apply_tts_engine_widget_fields(out3, node, "engine")
    assert "checkpoint" in out3 and "language" in out3 and "device" in out3


def test_extract_tts_fallback_and_no_sampler(monkeypatch):
    nodes = {
        "1": {"class_type": "UnifiedTTSTextNode", "inputs": {"text": "hello world"}},
        "2": {"class_type": "TTSEngine", "inputs": {"model": "m.safetensors"}},
    }
    monkeypatch.setattr(p, "_extract_input_files", lambda nodes_by_id: [{"filename": "a.wav"}])
    out = p._extract_tts_geninfo_fallback(nodes, {"workflow_type": "audio"})
    assert out and out["engine"]["type"] == "tts"

    monkeypatch.setattr(p, "_extract_tts_geninfo_fallback", lambda n, w: None)
    out2 = p._build_no_sampler_result(nodes, {"workflow_type": "audio"})
    assert out2.ok and out2.data.get("metadata")


def test_prompt_source_and_conditioning_helpers(monkeypatch):
    assert p._source_from_items([]) is None
    assert p._source_from_items([("p1", "s1"), ("p2", "s1")])[0] == "p1\np2"
    assert "(+1)" in p._source_from_items([("p1", "s1"), ("p2", "s2")])[1]

    out = p._extract_prompt_from_conditioning({}, ["1", 0], branch="positive")
    assert out is None or isinstance(out, tuple)


def test_select_sampler_context_and_special(monkeypatch):
    sid, conf, mode = p._select_sampler_context({}, "1")
    assert mode in {"primary", "advanced", "global", "fallback"}
    assert conf in {"none", "low", "medium", "high"}
    assert sid is None or isinstance(sid, str)


def test_collect_sampler_pipeline_from_sink_fallback_chain():
    nodes = {
        "1": {
            "class_type": "KSampler",
            "title": "Base Sampler",
            "inputs": {
                "steps": 20,
                "cfg": 6.5,
                "seed": 111,
                "sampler_name": "euler",
                "scheduler": "normal",
                "denoise": 1.0,
            },
        },
        "2": {
            "class_type": "KSampler",
            "title": "HighresFix",
            "inputs": {
                "steps": 12,
                "cfg": 5.0,
                "seed": 222,
                "sampler_name": "dpmpp_2m",
                "scheduler": "karras",
                "denoise": 0.45,
                "image": ["1", 0],
            },
        },
        "3": {
            "class_type": "SaveImage",
            "inputs": {
                "images": ["2", 0],
            },
        },
    }

    passes = p._collect_sampler_pipeline_from_sink(nodes, "3")
    assert len(passes) == 2
    assert passes[0].get("pass_name") == "Base"
    assert passes[1].get("pass_name") == "Refine / Upscale"


def test_collect_sampler_pipeline_from_sink_uses_proxy_widgets():
    nodes = {
        "1": {
            "class_type": "KSampler",
            "title": "Base Sampler",
            "inputs": {
                "steps": 20,
                "cfg": 6.5,
                "seed": 111,
                "sampler_name": "euler",
                "scheduler": "normal",
                "denoise": 1.0,
            },
        },
        "2": {
            "class_type": "Detailer",
            "title": "HighresFix",
            "inputs": {
                "image": ["1", 0],
                "model": ["9", 0],
            },
            "properties": {
                "proxyWidgets": [
                    ["x", "steps"],
                    ["x", "cfg"],
                    ["x", "sampler_name"],
                    ["x", "scheduler"],
                    ["x", "denoise"],
                    ["x", "seed"],
                ]
            },
            "widgets_values": [12, 4.0, "dpmpp_2m", "karras", 0.35, 222],
        },
        "3": {
            "class_type": "SaveImage",
            "inputs": {
                "images": ["2", 0],
            },
        },
    }

    passes = p._collect_sampler_pipeline_from_sink(nodes, "3")
    assert len(passes) == 2
    assert passes[1].get("sampler_name") == "dpmpp_2m"
    assert passes[1].get("scheduler") == "karras"
    assert passes[1].get("steps") == 12
    assert passes[1].get("cfg") == 4.0
    assert passes[1].get("denoise") == 0.35
    assert passes[1].get("seed_val") == 222


def test_collect_sampler_pipeline_keeps_identical_detailer_passes():
    nodes = {
        "1": {
            "class_type": "KSampler",
            "title": "Base Sampler",
            "inputs": {
                "steps": 20,
                "cfg": 6.5,
                "seed": 111,
                "sampler_name": "euler",
                "scheduler": "normal",
                "denoise": 1.0,
            },
        },
        "2": {
            "class_type": "DetailerForEach",
            "title": "Face Detailer",
            "inputs": {
                "image": ["1", 0],
                "steps": 20,
                "cfg": 6.5,
                "seed": 111,
                "sampler_name": "euler",
                "scheduler": "normal",
                "denoise": 1.0,
            },
        },
        "3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
    }

    passes = p._collect_sampler_pipeline_from_sink(nodes, "3")

    assert len(passes) == 2
    assert passes[0].get("seed_val") == 111
    assert passes[0].get("pass_name") == "Base"
    assert passes[1].get("seed_val") == 111
    assert passes[1].get("pass_name") == "Detailer"


def test_collect_sampler_pipeline_orders_rgthree_any_switch_branch_correctly():
    nodes = {
        "1": {
            "class_type": "KSampler",
            "title": "Base Sampler",
            "inputs": {
                "steps": 20,
                "cfg": 6.5,
                "seed": 111,
                "sampler_name": "euler",
                "scheduler": "normal",
                "denoise": 1.0,
            },
        },
        "2": {
            "class_type": "KSampler",
            "title": "Upscale Sampler",
            "inputs": {
                "steps": 12,
                "cfg": 5.0,
                "seed": 222,
                "sampler_name": "dpmpp_2m",
                "scheduler": "karras",
                "denoise": 0.4,
                "image": ["1", 0],
            },
        },
        "3": {
            "class_type": "DetailerForEach",
            "title": "Detailer",
            "inputs": {
                "image": ["2", 0],
                "steps": 8,
                "cfg": 4.0,
                "seed": 333,
                "sampler_name": "dpmpp_2m",
                "scheduler": "karras",
                "denoise": 0.35,
            },
        },
        "4": {
            "class_type": "RgthreeAnySwitch",
            "title": "any switch",
            "inputs": {
                "any_01": ["3", 0],
                "any_02": ["1", 0],
            },
        },
        "5": {"class_type": "SaveImage", "inputs": {"images": ["4", 0]}},
    }

    passes = p._collect_sampler_pipeline_from_sink(nodes, "5")

    assert [item.get("seed_val") for item in passes] == [111, 222, 333]
    assert [item.get("pass_name") for item in passes] == ["Base", "Refine / Upscale", "Detailer"]


def test_trace_scheduler_sigmas_resolves_linked_steps():
    nodes = {
        "1": {
            "class_type": "BasicScheduler",
            "inputs": {
                "scheduler": "karras",
                "steps": ["2", 0],
                "denoise": 1.0,
            },
        },
        "2": {
            "class_type": "Primitive",
            "inputs": {
                "value": 24,
            },
        },
    }

    steps, scheduler, denoise, _model_link, _src, _steps_conf = p._trace_scheduler_sigmas(nodes, ["1", 0])
    assert steps == 24
    assert scheduler == "karras"
    assert denoise == 1.0
