"""Tests for graphify.semantic_cleanup.validate_semantic_fragment (#825)."""

import json

from graphify import semantic_cleanup as sc


def _valid_fragment():
    return {
        "nodes": [{"id": "module_func", "label": "func", "file_type": "code"}],
        "edges": [{"source": "module_func", "target": "other_node"}],
        "hyperedges": [],
    }


def test_validate_semantic_fragment_accepts_valid():
    assert sc.validate_semantic_fragment(_valid_fragment()) == []


def test_validate_semantic_fragment_rejects_non_object():
    errors = sc.validate_semantic_fragment(["not", "an", "object"])
    assert any("object" in e.lower() for e in errors)


def test_validate_semantic_fragment_rejects_oversize_payload(monkeypatch):
    monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_BYTES", 64)
    fragment = _valid_fragment()
    fragment["nodes"][0]["label"] = "x" * 128
    errors = sc.validate_semantic_fragment(fragment)
    assert any("payload" in e.lower() for e in errors)


def test_validate_semantic_fragment_rejects_too_many_nodes(monkeypatch):
    monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_NODES", 1)
    fragment = _valid_fragment()
    fragment["nodes"].append({"id": "extra", "label": "extra", "file_type": "code"})
    errors = sc.validate_semantic_fragment(fragment)
    assert any("nodes" in e.lower() for e in errors)


def test_validate_semantic_fragment_rejects_too_many_edges(monkeypatch):
    monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_EDGES", 0)
    errors = sc.validate_semantic_fragment(_valid_fragment())
    assert any("edges" in e.lower() for e in errors)


def test_validate_semantic_fragment_rejects_path_separator_in_id():
    fragment = _valid_fragment()
    fragment["nodes"][0]["id"] = "../etc/passwd"
    errors = sc.validate_semantic_fragment(fragment)
    assert any("nodes[0].id" in e for e in errors)


def test_validate_semantic_fragment_accepts_unknown_file_type():
    """An unknown/synonym file_type is NOT a validation failure: build_from_json
    coerces any value via _FILE_TYPE_SYNONYMS (unknown -> "concept", #840), so
    rejecting a whole chunk over it would be pure data loss. file_type carries no
    security risk, so it is left to build's coercion rather than gated here."""
    fragment = _valid_fragment()
    fragment["nodes"][0]["file_type"] = "executable"
    errors = sc.validate_semantic_fragment(fragment)
    assert not any("file_type" in e for e in errors)


def test_validate_semantic_fragment_accepts_rationale_file_type():
    """LLM output with file_type='rationale' must pass validation so the cleanup
    pass can convert or remove it.  Validation must not reject it before cleanup runs."""
    fragment = _valid_fragment()
    fragment["nodes"][0]["file_type"] = "rationale"
    errors = sc.validate_semantic_fragment(fragment)
    assert not any("file_type" in e for e in errors), (
        f"'rationale' must be accepted by validate_semantic_fragment; got errors: {errors}"
    )


def test_validate_semantic_fragment_accepts_concept_file_type():
    """LLM output with file_type='concept' must pass validation for the same reason."""
    fragment = _valid_fragment()
    fragment["nodes"][0]["file_type"] = "concept"
    errors = sc.validate_semantic_fragment(fragment)
    assert not any("file_type" in e for e in errors), (
        f"'concept' must be accepted by validate_semantic_fragment; got errors: {errors}"
    )


def test_load_validated_semantic_fragment_accepts_valid(tmp_path):
    chunk = tmp_path / ".graphify_chunk_00.json"
    chunk.write_text(json.dumps(_valid_fragment()))
    fragment, errors = sc.load_validated_semantic_fragment(chunk)
    assert errors == []
    assert fragment == _valid_fragment()


def test_load_validated_semantic_fragment_rejects_oversize_before_parse(tmp_path, monkeypatch):
    """Oversize files are rejected by stat() — payload is never parsed."""
    monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_BYTES", 64)
    chunk = tmp_path / ".graphify_chunk_99.json"
    # Write something that would PARSE successfully if read, but exceeds the size guard.
    chunk.write_text("[" + ",".join(['"x"'] * 50) + "]")
    fragment, errors = sc.load_validated_semantic_fragment(chunk)
    assert fragment is None
    assert any("payload" in e.lower() for e in errors)


def test_load_validated_semantic_fragment_rejects_invalid_json(tmp_path):
    """Invalid JSON returns an error instead of raising."""
    chunk = tmp_path / ".graphify_chunk_bad.json"
    chunk.write_text("{not valid json")
    fragment, errors = sc.load_validated_semantic_fragment(chunk)
    assert fragment is None
    assert any("invalid json" in e.lower() for e in errors)


# ---------------------------------------------------------------------------
# Hyperedge validation (F2)
# ---------------------------------------------------------------------------


def test_validate_hyperedge_rejects_bad_id():
    fragment = _valid_fragment()
    fragment["hyperedges"] = [
        {"id": "../escape", "label": "x", "nodes": ["module_func", "module_func"]}
    ]
    errors = sc.validate_semantic_fragment(fragment)
    assert any("hyperedges[0].id" in e for e in errors)


def test_validate_hyperedge_rejects_bad_node_ref():
    fragment = _valid_fragment()
    fragment["hyperedges"] = [
        {"id": "valid_he", "label": "x", "nodes": ["module_func", "../bad_ref"]}
    ]
    errors = sc.validate_semantic_fragment(fragment)
    assert any("hyperedges[0].nodes[1]" in e for e in errors)


def test_validate_hyperedge_requires_list():
    fragment = _valid_fragment()
    fragment["hyperedges"] = [{"id": "valid_he", "label": "x", "nodes": "not a list"}]
    errors = sc.validate_semantic_fragment(fragment)
    assert any("hyperedges[0].nodes" in e for e in errors)


def test_validate_hyperedge_caps_count(monkeypatch):
    monkeypatch.setattr(sc, "MAX_SEMANTIC_FRAGMENT_HYPEREDGES", 1)
    fragment = _valid_fragment()
    fragment["hyperedges"] = [
        {"id": f"he_{i}", "label": "x", "nodes": ["module_func", "module_func"]} for i in range(3)
    ]
    errors = sc.validate_semantic_fragment(fragment)
    assert any("hyperedges has 3" in e for e in errors)


# ---------------------------------------------------------------------------
# Sanitizer behavior (F3 + F4 + rationale conversion)
# ---------------------------------------------------------------------------


def test_sanitize_drops_rationale_filetype_node():
    """A node with file_type='rationale' is removed wholesale."""
    fragment = {
        "nodes": [
            {"id": "real_node", "label": "Real", "file_type": "code"},
            {"id": "garbage", "label": "junk", "file_type": "rationale"},
        ],
        "edges": [],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    ids = {n["id"] for n in out["nodes"]}
    assert "real_node" in ids
    assert "garbage" not in ids


def test_sanitize_converts_sentence_rationale_node_to_attribute():
    """Sentence-like rationale node connected via `rationale_for` → attribute on target."""
    fragment = {
        "nodes": [
            {"id": "real_node", "label": "Real", "file_type": "code"},
            {
                "id": "why_node",
                "label": "We chose tree-sitter because the deterministic parser is faster than regex-based extraction.",
                "file_type": "rationale",
            },
        ],
        "edges": [{"source": "why_node", "target": "real_node", "relation": "rationale_for"}],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    ids = {n["id"] for n in out["nodes"]}
    assert "why_node" not in ids
    target = next(n for n in out["nodes"] if n["id"] == "real_node")
    assert "tree-sitter" in target.get("rationale", "")


def test_sanitize_converts_allowed_filetype_sentence_via_rationale_for_edge():
    """F3: a node with file_type='document' (allowed) that is BOTH sentence-like
    AND sources a `rationale_for` edge is still cleaned to an attribute."""
    fragment = {
        "nodes": [
            {"id": "real_node", "label": "Real", "file_type": "code"},
            {
                "id": "sentence_node",
                "label": (
                    "Decision: this node has sentence-like rationale text but uses an "
                    "allowed file_type, so it should not survive as a standalone graph node."
                ),
                "file_type": "document",
            },
        ],
        "edges": [{"source": "sentence_node", "target": "real_node", "relation": "rationale_for"}],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    ids = {n["id"] for n in out["nodes"]}
    assert "sentence_node" not in ids
    target = next(n for n in out["nodes"] if n["id"] == "real_node")
    assert "Decision" in target.get("rationale", "")


def test_sanitize_keeps_short_concept_named_node_with_punctuation():
    """A short named node with a period (e.g. abbreviation) is NOT sentence-like."""
    fragment = {
        "nodes": [
            {"id": "a_b", "label": "a.b.c", "file_type": "document"},
            {"id": "anchor", "label": "Anchor", "file_type": "code"},
        ],
        "edges": [{"source": "a_b", "target": "anchor", "relation": "rationale_for"}],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    ids = {n["id"] for n in out["nodes"]}
    assert "a_b" in ids
    assert "anchor" in ids


def test_sanitize_filters_hyperedges_after_node_removal():
    """F4: hyperedges referencing removed nodes are repaired or dropped."""
    fragment = {
        "nodes": [
            {"id": "real_node", "label": "Real", "file_type": "code"},
            {"id": "other", "label": "Other", "file_type": "code"},
            {"id": "garbage", "label": "junk", "file_type": "rationale"},
        ],
        "edges": [],
        "hyperedges": [
            {
                "id": "group_a",
                "label": "Group A",
                "nodes": ["garbage", "real_node", "other"],
                "relation": "participate_in",
            },
            {
                "id": "group_b",
                "label": "Group B (only one survivor)",
                "nodes": ["garbage", "real_node"],
                "relation": "participate_in",
            },
        ],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    he_ids = {he["id"] for he in out["hyperedges"]}
    # group_a survives with garbage filtered out
    assert "group_a" in he_ids
    group_a = next(he for he in out["hyperedges"] if he["id"] == "group_a")
    assert "garbage" not in group_a["nodes"]
    assert set(group_a["nodes"]) == {"real_node", "other"}
    # group_b had only 1 surviving member → dropped
    assert "group_b" not in he_ids


def test_sanitize_drops_hyperedge_with_only_unknown_refs():
    """A hyperedge referencing only nodes not present in the fragment is dropped."""
    fragment = {
        "nodes": [{"id": "real_node", "label": "Real", "file_type": "code"}],
        "edges": [],
        "hyperedges": [{"id": "phantom", "label": "Phantom", "nodes": ["ghost1", "ghost2"]}],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    assert out["hyperedges"] == []


def test_sanitize_boundary_sentence_threshold():
    """Boundary: a label with exactly 8 words + colon is sentence-like;
    a 7-word label without sentence punctuation is not."""
    # 8 words, has colon → sentence-like
    long_label = "Note: alpha beta gamma delta epsilon zeta eta"
    fragment = {
        "nodes": [
            {"id": "anchor", "label": "Anchor", "file_type": "code"},
            {"id": "n1", "label": long_label, "file_type": "rationale"},
        ],
        "edges": [{"source": "n1", "target": "anchor", "relation": "rationale_for"}],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    assert {n["id"] for n in out["nodes"]} == {"anchor"}
    anchor = out["nodes"][0]
    assert "alpha" in anchor.get("rationale", "")

    # 7 words no terminal punctuation → not sentence-like
    short_label = "alpha beta gamma delta epsilon zeta eta"
    fragment = {
        "nodes": [
            {"id": "anchor", "label": "Anchor", "file_type": "code"},
            {"id": "n2", "label": short_label, "file_type": "rationale"},
        ],
        "edges": [],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    # n2 has file_type=rationale, so it's still removed via pass 1 — but should NOT
    # become a rationale attribute on anchor (no rationale_for edge, no sentence pattern).
    assert {n["id"] for n in out["nodes"]} == {"anchor"}
    assert "rationale" not in out["nodes"][0]


def test_sanitize_rationale_only_propagates_through_rationale_for_edges():
    """A rationale node connected to ONE target via `rationale_for` and to ANOTHER
    target via a non-rationale-for relation must NOT attach the rationale text
    to the second target. Codex v2 caught the bug where every outgoing edge
    propagated the rationale, corrupting unrelated nodes."""
    fragment = {
        "nodes": [
            {"id": "rationale_target", "label": "Rationale Target", "file_type": "code"},
            {"id": "unrelated_target", "label": "Unrelated Target", "file_type": "code"},
            {
                "id": "why_node",
                "label": (
                    "Decision: we chose tree-sitter because the deterministic parser "
                    "is faster than regex-based extraction."
                ),
                "file_type": "rationale",
            },
        ],
        "edges": [
            {"source": "why_node", "target": "rationale_target", "relation": "rationale_for"},
            {"source": "why_node", "target": "unrelated_target", "relation": "references"},
        ],
        "hyperedges": [],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    ids = {n["id"]: n for n in out["nodes"]}
    assert "why_node" not in ids
    # rationale_target should have the rationale attribute
    assert "tree-sitter" in ids["rationale_target"].get("rationale", "")
    # unrelated_target should NOT have rationale leaked from the `references` edge
    assert "rationale" not in ids["unrelated_target"]


def test_sanitize_keeps_members_keyed_hyperedge(capsys):
    """#1561: a `members`-keyed hyperedge with >=2 surviving members must be
    KEPT (normalized to `nodes`), not silently dropped before build."""
    fragment = {
        "nodes": [
            {"id": "real_a", "label": "A", "file_type": "code"},
            {"id": "real_b", "label": "B", "file_type": "code"},
        ],
        "edges": [],
        "hyperedges": [
            {"id": "grp", "label": "Group", "members": ["real_a", "real_b"]},
        ],
    }
    out = sc.sanitize_semantic_fragment(fragment)
    assert len(out["hyperedges"]) == 1
    he = out["hyperedges"][0]
    assert he["id"] == "grp"
    assert he["nodes"] == ["real_a", "real_b"]
    assert "members" not in he


def test_validate_accepts_node_ids_keyed_hyperedge():
    """#1561: an alias-keyed hyperedge must not be rejected for a missing
    `nodes` list — validate normalizes first."""
    fragment = _valid_fragment()
    fragment["nodes"].append({"id": "second", "label": "Second", "file_type": "code"})
    fragment["hyperedges"] = [
        {"id": "grp", "label": "G", "node_ids": ["module_func", "second"]}
    ]
    errors = sc.validate_semantic_fragment(fragment)
    assert errors == []
