"""JMeter rules JMX001-JMX008."""

from __future__ import annotations

import copy
import re

from lxml import etree

from perf_lint.ir.models import Framework, Location, Severity, Violation
from perf_lint.ir.models import ScriptIR
from perf_lint.rules.base import BaseRule, RuleRegistry
from perf_lint.xml_utils import _SECURE_PARSER


def _parse_jmx(raw: str) -> etree._Element | None:
    """Parse a JMX string into an lxml Element, or return None on failure."""
    try:
        return etree.fromstring(raw.encode("utf-8"), _SECURE_PARSER)
    except etree.XMLSyntaxError:
        return None


def _serialize_jmx(root: etree._Element) -> str:
    """Serialize an lxml Element back to a UTF-8 XML string with declaration.

    Uses lxml's built-in pretty_print — no post-processing needed.
    Mirrors jmeter-cli-editor's JMXSerializer.serialize_to_string().
    """
    xml_bytes = etree.tostring(
        root,
        encoding="UTF-8",
        xml_declaration=True,
        pretty_print=True,
    )
    return xml_bytes.decode("UTF-8")


def _find_thread_group_hashtree(root: etree._Element) -> etree._Element | None:
    """Return the hashTree that immediately follows the first ThreadGroup element.

    Uses lxml's getnext() — the idiom from jmeter-cli-editor's JMXDocument —
    which is more robust than walking by list index because it doesn't depend
    on element count or ordering.

    JMeter structure:
      jmeterTestPlan > hashTree > hashTree
        ThreadGroup
        hashTree   ← this is what we return (ThreadGroup's direct children)
          CacheManager, CookieManager, HTTPSamplerProxy, ...
    """
    tg = root.find(".//ThreadGroup")
    if tg is None:
        return None
    tg_ht = tg.getnext()
    if tg_ht is None or tg_ht.tag != "hashTree":
        return None
    return tg_ht


def _insert_into_tg_hashtree(root: etree._Element, elem: etree._Element) -> bool:
    """Insert elem + companion hashTree at position 0 of the thread group hashTree.
    Returns True if insertion was successful, False if no thread group hashTree found."""
    tg_ht = _find_thread_group_hashtree(root)
    if tg_ht is None:
        return False
    tg_ht.insert(0, etree.Element("hashTree"))
    tg_ht.insert(0, elem)
    return True


def _make_cache_manager() -> etree._Element:
    """Create a CacheManager element matching the style of the test fixtures."""
    elem = etree.Element("CacheManager")
    elem.set("guiclass", "CacheManagerGui")
    elem.set("testclass", "CacheManager")
    elem.set("testname", "HTTP Cache Manager")
    elem.set("enabled", "true")
    return elem


def _make_cookie_manager() -> etree._Element:
    """Create a CookieManager element using jmeter-cli-editor's template structure."""
    elem = etree.Element("CookieManager")
    elem.set("guiclass", "CookiePanel")
    elem.set("testclass", "CookieManager")
    elem.set("testname", "HTTP Cookie Manager")
    elem.set("enabled", "true")
    # Properties — from jmeter-cli-editor's ELEMENT_TEMPLATES["CookieManager"]
    for name, value in (
        ("CookieManager.clearEachIteration", "false"),
        ("CookieManager.controlledByThreadGroup", "false"),
    ):
        prop = etree.SubElement(elem, "boolProp")
        prop.set("name", name)
        prop.text = value
    coll = etree.SubElement(elem, "collectionProp")
    coll.set("name", "CookieManager.cookies")
    return elem


def _make_http_defaults(
    domain: str = "${HOST}",
    port: str = "${PORT}",
    protocol: str = "${PROTOCOL}",
    connect_timeout: str = "5000",
    response_timeout: str = "30000",
) -> etree._Element:
    """Create a ConfigTestElement (HTTP Request Defaults) with the structure
    that JMeter's HttpDefaultsGui.configure() requires.

    JMeter's UrlConfigGui.configure() unconditionally reads
    'HTTPsampler.Arguments' (note lowercase 's') and passes the result to
    ArgumentsPanel.configure(). If the elementProp is absent the value is null
    and JMeter throws a NullPointerException when loading the file.
    """
    elem = etree.Element("ConfigTestElement")
    elem.set("guiclass", "HttpDefaultsGui")
    elem.set("testclass", "ConfigTestElement")
    elem.set("testname", "HTTP Request Defaults")
    elem.set("enabled", "true")
    # Required by HttpDefaultsGui — must be present even when empty.
    args_prop = etree.SubElement(elem, "elementProp")
    args_prop.set("name", "HTTPsampler.Arguments")
    args_prop.set("elementType", "Arguments")
    coll = etree.SubElement(args_prop, "collectionProp")
    coll.set("name", "Arguments.arguments")
    for name, value in [
        ("HTTPSampler.domain", domain),
        ("HTTPSampler.port", port),
        ("HTTPSampler.protocol", protocol),
        ("HTTPSampler.connect_timeout", connect_timeout),
        ("HTTPSampler.response_timeout", response_timeout),
    ]:
        prop = etree.SubElement(elem, "stringProp")
        prop.set("name", name)
        prop.text = value
    return elem


def _make_response_assertion() -> etree._Element:
    """Create a ResponseAssertion that checks for HTTP 200 response code."""
    elem = etree.Element("ResponseAssertion")
    elem.set("guiclass", "AssertionGui")
    elem.set("testclass", "ResponseAssertion")
    elem.set("testname", "Response Assertion")
    elem.set("enabled", "true")
    # Test strings collection — must contain at least one pattern or the assertion
    # passes vacuously (hollow assertion).
    coll = etree.SubElement(elem, "collectionProp")
    coll.set("name", "Assertion.test_strings")
    pattern = etree.SubElement(coll, "stringProp")
    # JMeter uses a numeric key derived from the pattern's hash; "49586" is the
    # well-known key for the literal string "200" in JMeter's GUI.
    pattern.set("name", "49586")
    pattern.text = "200"
    # Assert on the response code, not the body.
    test_field = etree.SubElement(elem, "stringProp")
    test_field.set("name", "Assertion.test_field")
    test_field.text = "Assertion.response_code"
    # test_type 8 = EQUALS (intProp, not stringProp — JMeter reads this as int).
    test_type = etree.SubElement(elem, "intProp")
    test_type.set("name", "Assertion.test_type")
    test_type.text = "8"
    custom_msg = etree.SubElement(elem, "stringProp")
    custom_msg.set("name", "Assertion.custom_message")
    custom_msg.text = ""
    return elem


_BEANSHELL_TO_JSR223 = {
    "BeanShellSampler": ("JSR223Sampler", "TestBeanGUI"),
    "BeanShellPreProcessor": ("JSR223PreProcessor", "TestBeanGUI"),
    "BeanShellPostProcessor": ("JSR223PostProcessor", "TestBeanGUI"),
    "BeanShellListener": ("JSR223Listener", "TestBeanGUI"),
}


@RuleRegistry.register
class JMX001MissingCacheManager(BaseRule):
    rule_id = "JMX001"
    name = "MissingCacheManager"
    description = "HTTP Cache Manager is missing. Without it, JMeter won't simulate browser caching, producing unrealistically high load."
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("realism", "http")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        config_elements = ir.parsed_data.get("config_elements", [])
        has_cache = any("CacheManager" in el for el in config_elements)
        if not has_cache:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message="No HTTP Cache Manager found. Add one to simulate realistic browser caching behaviour.",
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add an HTTP Cache Manager config element to your Thread Group.",
                    fix_example='<CacheManager guiclass="CacheManagerGui" testclass="CacheManager" testname="HTTP Cache Manager" enabled="true"/>',
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//CacheManager"):
            return None
        if not _insert_into_tg_hashtree(root, _make_cache_manager()):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX002MissingCookieManager(BaseRule):
    rule_id = "JMX002"
    name = "MissingCookieManager"
    description = "HTTP Cookie Manager is missing. Sessions won't be maintained between requests."
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("realism", "http", "sessions")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        config_elements = ir.parsed_data.get("config_elements", [])
        has_cookies = any("CookieManager" in el for el in config_elements)
        if not has_cookies:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message="No HTTP Cookie Manager found. Sessions won't be maintained between requests, producing unrealistic results.",
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add an HTTP Cookie Manager config element to your Thread Group.",
                    fix_example='<CookieManager guiclass="CookiePanel" testclass="CookieManager" testname="HTTP Cookie Manager" enabled="true"/>',
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//CookieManager"):
            return None
        if not _insert_into_tg_hashtree(root, _make_cookie_manager()):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX003ConstantTimerOnly(BaseRule):
    rule_id = "JMX003"
    name = "ConstantTimerOnly"
    description = "Only Constant Timers used. Real users don't think at perfectly regular intervals — use Gaussian or Uniform random timers."
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("realism", "think-time")

    def check(self, ir: ScriptIR) -> list[Violation]:
        timers = ir.parsed_data.get("timers", [])
        if timers and all("ConstantTimer" in t for t in timers):
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=f"Only ConstantTimer found ({len(timers)} timer(s)). Real think times are variable.",
                    location=Location(element_path="/jmeterTestPlan//ConstantTimer"),
                    suggestion="Replace ConstantTimer with GaussianRandomTimer or UniformRandomTimer for more realistic think times.",
                    fix_example='<GaussianRandomTimer guiclass="GaussianRandomTimerGui" testname="Random Think Time">\n  <stringProp name="ConstantTimer.delay">1000</stringProp>\n  <stringProp name="RandomTimer.range">500</stringProp>\n</GaussianRandomTimer>',
                )
            ]
        return []


@RuleRegistry.register
class JMX004RampupTooShort(BaseRule):
    rule_id = "JMX004"
    name = "RampupTooShort"
    description = "Ramp-up period is too short relative to thread count (< 0.5 seconds per thread), causing an unrealistic spike load."
    severity = Severity.ERROR
    frameworks = [Framework.JMETER]
    tags = ("rampup", "realism")
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        violations = []
        for i, tg in enumerate(ir.parsed_data.get("thread_groups", [])):
            # Skip thread groups that use JMeter expressions for thread count
            # or ramp time — _safe_int() returns a default of 1 for these, which
            # would produce false-positive violations.
            if tg.get("has_dynamic_values"):
                continue
            num_threads = tg.get("num_threads", 1)
            ramp_time = tg.get("ramp_time", 1)
            if num_threads > 1 and ramp_time / num_threads < 0.5:
                violations.append(
                    Violation(
                        rule_id=self.rule_id,
                        severity=self.severity,
                        message=(
                            f"Thread Group {i + 1}: ramp_time={ramp_time}s for {num_threads} threads "
                            f"({ramp_time / num_threads:.2f}s/thread). Minimum recommended: 0.5s/thread."
                        ),
                        location=Location(element_path=f"/jmeterTestPlan//ThreadGroup[{i + 1}]"),
                        suggestion=f"Set ramp_time to at least {int(num_threads * 0.5)}s for {num_threads} threads.",
                    )
                )
        return violations


@RuleRegistry.register
class JMX005MissingAssertion(BaseRule):
    rule_id = "JMX005"
    name = "MissingAssertion"
    description = "No assertions found. Without assertions, the test cannot detect application failures — passing tests mean nothing."
    severity = Severity.ERROR
    frameworks = [Framework.JMETER]
    tags = ("assertions", "correctness")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("assertion_count", 0) == 0:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message="No assertions found. Tests that can't fail aren't tests — add response assertions.",
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add a Response Assertion to verify HTTP 200 status codes and response content.",
                    fix_example='<ResponseAssertion guiclass="AssertionGui" testname="Response Assertion">\n  <stringProp name="Assertion.test_type">2</stringProp>\n  <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>\n  <stringProp name="Assertion.custom_message"/>\n</ResponseAssertion>',
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        if ir.parsed_data.get("assertion_count", 0) > 0:
            return None  # Already has assertions — nothing to fix
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None

        # Use getnext() — the jmeter-cli-editor idiom — to navigate directly
        # to each sampler's sibling hashTree without index arithmetic.
        # This is immune to the mutation-while-iterating bug in the old approach.
        inserted = False
        for sampler in root.iter("HTTPSamplerProxy"):
            sampler_ht = sampler.getnext()
            if sampler_ht is not None and sampler_ht.tag == "hashTree":
                sampler_ht.append(copy.deepcopy(_make_response_assertion()))
                sampler_ht.append(etree.Element("hashTree"))  # companion hashTree for ResponseAssertion
            else:
                # Sampler has no following hashTree (common in hand-written JMX
                # fixtures) — create one with addnext(), same as JMXDocument.add_child()
                new_ht = etree.Element("hashTree")
                new_ht.append(_make_response_assertion())
                new_ht.append(etree.Element("hashTree"))  # companion hashTree for ResponseAssertion
                sampler.addnext(new_ht)
            inserted = True

        if not inserted:
            return None

        return _serialize_jmx(root)


@RuleRegistry.register
class JMX006HardcodedHostInSampler(BaseRule):
    rule_id = "JMX006"
    name = "HardcodedHostInSampler"
    description = "Sampler uses a hardcoded IP address instead of a hostname or variable. This prevents running the test against different environments."
    severity = Severity.ERROR
    frameworks = [Framework.JMETER]
    tags = ("parameterization", "portability")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        violations = []
        for i, sampler in enumerate(ir.parsed_data.get("samplers", [])):
            if sampler.get("is_hardcoded_ip"):
                violations.append(
                    Violation(
                        rule_id=self.rule_id,
                        severity=self.severity,
                        message=f"Sampler {i + 1} uses hardcoded IP '{sampler['domain']}'. Use a hostname or ${'{HOST}'} variable instead.",
                        location=Location(element_path=f"/jmeterTestPlan//HTTPSamplerProxy[{i + 1}]"),
                        suggestion="Replace the IP address with a hostname or parameterise it using a User Defined Variables config element.",
                        fix_example='<stringProp name="HTTPSampler.domain">${HOST}</stringProp>',
                    )
                )
        return violations

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        for hs in root.findall(".//HTTPSamplerProxy"):
            domain_el = hs.find("./stringProp[@name='HTTPSampler.domain']")
            if domain_el is not None and domain_el.text:
                if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", domain_el.text.strip()):
                    domain_el.text = "${HOST}"
                    changed = True
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX007MissingCSVDataSet(BaseRule):
    rule_id = "JMX007"
    name = "MissingCSVDataSet"
    description = "Multiple samplers but no CSV Data Set. Using static data means all virtual users send identical requests, producing unrealistic results."
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("parameterization", "realism")
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        sampler_count = ir.parsed_data.get("sampler_count", 0)
        csv_count = ir.parsed_data.get("csv_data_set_count", 0)
        if sampler_count > 3 and csv_count == 0:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=f"Found {sampler_count} samplers but no CSV Data Set config. Consider parameterising test data.",
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add a CSV Data Set Config element to drive requests with varied test data (users, products, search terms, etc.).",
                    fix_example='<CSVDataSet guiclass="TestBeanGUI" testname="CSV Data Set Config">\n  <stringProp name="filename">test-data.csv</stringProp>\n  <stringProp name="variableNames">USER_ID,EMAIL</stringProp>\n</CSVDataSet>',
                )
            ]
        return []


@RuleRegistry.register
class JMX008NoVariableUsage(BaseRule):
    rule_id = "JMX008"
    name = "NoVariableUsage"
    description = "Multiple samplers but no JMeter variables (${VAR}) used. All virtual users send identical static requests."
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("parameterization", "realism")
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        sampler_count = ir.parsed_data.get("sampler_count", 0)
        uses_variables = ir.parsed_data.get("uses_variables", False)
        if sampler_count > 1 and not uses_variables:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=f"Found {sampler_count} samplers with no variable usage (${'{VAR}'}). Test data is fully static.",
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Use JMeter variables (${VARIABLE_NAME}) to parameterise URLs, headers, and request bodies.",
                    fix_example='<stringProp name="HTTPSampler.path">/api/users/${USER_ID}</stringProp>',
                )
            ]
        return []


# ---------------------------------------------------------------------------
# Helpers for JMX009 and JMX011
# ---------------------------------------------------------------------------

def _make_header_manager() -> etree._Element:
    """Create an HTTP Header Manager with a realistic default set of headers.

    Structure mirrors the perf-recorder output format.
    """
    elem = etree.Element("HeaderManager")
    elem.set("guiclass", "HeaderPanel")
    elem.set("testclass", "HeaderManager")
    elem.set("testname", "HTTP Headers")
    elem.set("enabled", "true")
    coll = etree.SubElement(elem, "collectionProp")
    coll.set("name", "HeaderManager.headers")
    for name, value in (
        ("User-Agent", "Mozilla/5.0 (compatible; perf-test)"),
        ("Accept", "application/json, text/html, */*"),
        ("Accept-Encoding", "gzip, deflate, br"),
        ("Accept-Language", "en-GB,en;q=0.9"),
    ):
        entry = etree.SubElement(coll, "elementProp")
        entry.set("name", name)
        entry.set("elementType", "Header")
        name_prop = etree.SubElement(entry, "stringProp")
        name_prop.set("name", "Header.name")
        name_prop.text = name
        val_prop = etree.SubElement(entry, "stringProp")
        val_prop.set("name", "Header.value")
        val_prop.text = value
    return elem


def _make_duration_assertion(duration_ms: int = 5000) -> etree._Element:
    """Create a DurationAssertion element with the given threshold in milliseconds."""
    elem = etree.Element("DurationAssertion")
    elem.set("guiclass", "DurationAssertionGui")
    elem.set("testclass", "DurationAssertion")
    elem.set("testname", "Duration Assertion")
    elem.set("enabled", "true")
    prop = etree.SubElement(elem, "stringProp")
    prop.set("name", "DurationAssertion.duration")
    prop.text = str(duration_ms)
    return elem


# ---------------------------------------------------------------------------
# JMX009–JMX013
# ---------------------------------------------------------------------------

@RuleRegistry.register
class JMX009MissingHeaderManager(BaseRule):
    rule_id = "JMX009"
    name = "MissingHeaderManager"
    description = (
        "No HTTP Header Manager found. Without common request headers "
        "(User-Agent, Accept-Encoding, Accept), requests won't simulate real browser behaviour "
        "and the server may serve different content or skip compression."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("realism", "http")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) == 0:
            return []
        config_elements = ir.parsed_data.get("config_elements", [])
        has_headers = any("HeaderManager" in el for el in config_elements)
        if not has_headers:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        "No HTTP Header Manager found. Without it, requests lack browser-like "
                        "headers such as User-Agent and Accept-Encoding."
                    ),
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add an HTTP Header Manager to your Thread Group with realistic browser headers.",
                    fix_example=(
                        '<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" '
                        'testname="HTTP Headers" enabled="true">\n'
                        '  <collectionProp name="HeaderManager.headers">\n'
                        '    <elementProp name="User-Agent" elementType="Header">\n'
                        '      <stringProp name="Header.name">User-Agent</stringProp>\n'
                        '      <stringProp name="Header.value">Mozilla/5.0 (compatible; perf-test)</stringProp>\n'
                        '    </elementProp>\n'
                        '  </collectionProp>\n'
                        '</HeaderManager>'
                    ),
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//HeaderManager"):
            return None
        if not _insert_into_tg_hashtree(root, _make_header_manager()):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX010NoHTTPDefaults(BaseRule):
    rule_id = "JMX010"
    name = "NoHTTPDefaults"
    description = (
        "No HTTP Request Defaults (ConfigTestElement) found. Without it, host, port, and protocol "
        "are duplicated in every sampler, making it difficult to point the test at a different environment."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("portability", "maintainability")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) < 2:
            return []
        config_elements = ir.parsed_data.get("config_elements", [])
        has_defaults = any("ConfigTestElement" in el for el in config_elements)
        if not has_defaults:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        "No HTTP Request Defaults found. Host, port, and protocol are repeated in "
                        f"{ir.parsed_data.get('http_sampler_count', 0)} samplers."
                    ),
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion=(
                        "Add an HTTP Request Defaults config element with the target host/port/protocol. "
                        "Leave those fields empty in each sampler."
                    ),
                    fix_example=(
                        '<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" '
                        'testname="HTTP Request Defaults" enabled="true">\n'
                        '  <elementProp name="HTTPsampler.Arguments" elementType="Arguments">\n'
                        '    <collectionProp name="Arguments.arguments"/>\n'
                        '  </elementProp>\n'
                        '  <stringProp name="HTTPSampler.domain">${HOST}</stringProp>\n'
                        '  <stringProp name="HTTPSampler.port">${PORT}</stringProp>\n'
                        '  <stringProp name="HTTPSampler.protocol">${PROTOCOL}</stringProp>\n'
                        '  <stringProp name="HTTPSampler.connect_timeout">5000</stringProp>\n'
                        '  <stringProp name="HTTPSampler.response_timeout">30000</stringProp>\n'
                        '</ConfigTestElement>'
                    ),
                )
            ]
        # Detect an existing ConfigTestElement that is missing the required
        # elementProp — this was created by an older perf-lint --fix and will
        # crash JMeter's GUI with a NullPointerException when loaded.
        if not ir.parsed_data.get("http_defaults_has_args_prop", True):
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        "HTTP Request Defaults exists but is missing the required "
                        "'HTTPsampler.Arguments' elementProp. JMeter will throw a "
                        "NullPointerException (cannot invoke getName() because element is null) "
                        "when this file is opened in the GUI."
                    ),
                    location=Location(element_path="/jmeterTestPlan//ConfigTestElement"),
                    suggestion="Run perf-lint check --fix to repair the element automatically.",
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        existing = root.findall(".//ConfigTestElement[@guiclass='HttpDefaultsGui']")
        if existing:
            # Repair any existing element missing the required elementProp.
            # JMeter's UrlConfigGui.configure() reads HTTPsampler.Arguments
            # unconditionally; if absent it passes null to ArgumentsPanel,
            # causing a NullPointerException when the file is loaded in the GUI.
            changed = False
            for cte in existing:
                if cte.find("./elementProp[@name='HTTPsampler.Arguments']") is None:
                    args_prop = etree.Element("elementProp")
                    args_prop.set("name", "HTTPsampler.Arguments")
                    args_prop.set("elementType", "Arguments")
                    coll = etree.SubElement(args_prop, "collectionProp")
                    coll.set("name", "Arguments.arguments")
                    cte.insert(0, args_prop)
                    changed = True
            return _serialize_jmx(root) if changed else None
        if not _insert_into_tg_hashtree(root, _make_http_defaults()):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX011NoDurationAssertion(BaseRule):
    rule_id = "JMX011"
    name = "NoDurationAssertion"
    description = (
        "No Duration Assertion found. Response-time SLAs are not enforced — slow responses will "
        "pass silently even when they breach your performance objectives."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("assertions", "slo")
    fixable = True
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) == 0:
            return []
        if ir.parsed_data.get("duration_assertion_count", 0) == 0:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        "No Duration Assertion found. Add one to enforce response-time SLAs and "
                        "fail the test when requests exceed your threshold."
                    ),
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion="Add a Duration Assertion to the Thread Group (applies to all samplers).",
                    fix_example=(
                        '<DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" '
                        'testname="Duration Assertion" enabled="true">\n'
                        '  <stringProp name="DurationAssertion.duration">5000</stringProp>\n'
                        '</DurationAssertion>'
                    ),
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//DurationAssertion"):
            return None
        if not _insert_into_tg_hashtree(root, _make_duration_assertion()):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX012NoResultCollector(BaseRule):
    rule_id = "JMX012"
    name = "NoResultCollector"
    description = (
        "No result listener (ResultCollector or BackendListener) found. "
        "Without one, the test has no persistent results when run from the GUI."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("observability", "results")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("sampler_count", 0) == 0:
            return []
        if ir.parsed_data.get("listener_count", 0) == 0:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        "No ResultCollector or BackendListener found. "
                        "Add a listener to capture results when running from the GUI."
                    ),
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion=(
                        "Add a View Results Tree or Summary Report listener for local runs, "
                        "or a BackendListener (e.g. InfluxDB) for CI/CD pipelines."
                    ),
                    fix_example=(
                        '<ResultCollector guiclass="SummaryReport" testclass="ResultCollector" '
                        'testname="Summary Report" enabled="true">\n'
                        '  <boolProp name="ResultCollector.error_logging">false</boolProp>\n'
                        '  <stringProp name="filename">results.jtl</stringProp>\n'
                        '</ResultCollector>'
                    ),
                )
            ]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//ResultCollector") or root.findall(".//BackendListener"):
            return None
        # Find the outer hashTree (child of root that contains TestPlan)
        outer_ht = root.find("./hashTree")
        if outer_ht is None:
            return None
        # The inner hashTree is the second child of outer_ht (after TestPlan)
        inner_ht = outer_ht.find("./hashTree")
        if inner_ht is None:
            return None
        elem = etree.Element("ResultCollector")
        elem.set("guiclass", "SummaryReport")
        elem.set("testclass", "ResultCollector")
        elem.set("testname", "Summary Report")
        elem.set("enabled", "true")
        bool_prop = etree.SubElement(elem, "boolProp")
        bool_prop.set("name", "ResultCollector.error_logging")
        bool_prop.text = "false"
        str_prop = etree.SubElement(elem, "stringProp")
        str_prop.set("name", "filename")
        str_prop.text = "results.jtl"
        inner_ht.append(elem)
        inner_ht.append(etree.Element("hashTree"))
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX013MissingTransactionController(BaseRule):
    rule_id = "JMX013"
    name = "MissingTransactionController"
    description = (
        "Multiple samplers but no Transaction Controller. Without transaction grouping, "
        "JMeter cannot report end-to-end response times for logical user journeys "
        "(e.g. login + browse + checkout as one transaction)."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("structure", "reporting")

    def check(self, ir: ScriptIR) -> list[Violation]:
        sampler_count = ir.parsed_data.get("sampler_count", 0)
        if sampler_count < 5:
            return []
        if ir.parsed_data.get("transaction_controller_count", 0) == 0:
            return [
                Violation(
                    rule_id=self.rule_id,
                    severity=self.severity,
                    message=(
                        f"Found {sampler_count} samplers but no Transaction Controller. "
                        "Group related requests into transactions for meaningful end-to-end metrics."
                    ),
                    location=Location(element_path="/jmeterTestPlan"),
                    suggestion=(
                        "Wrap logical user journeys (login, browse, checkout) in Transaction Controllers "
                        "so JMeter reports composite response times."
                    ),
                    fix_example=(
                        '<TransactionController guiclass="TransactionControllerGui" '
                        'testclass="TransactionController" testname="Login Journey" enabled="true">\n'
                        '  <boolProp name="TransactionController.includeTimers">false</boolProp>\n'
                        '</TransactionController>'
                    ),
                )
            ]
        return []


# ---------------------------------------------------------------------------
# JMX014–JMX025
# ---------------------------------------------------------------------------


@RuleRegistry.register
class JMX014BeanShellUsage(BaseRule):
    rule_id = "JMX014"
    name = "BeanShellUsage"
    description = (
        "BeanShell sampler or processor detected. BeanShell is single-threaded, "
        "GC-unfriendly, and deprecated since JMeter 3.1. Under load it serialises "
        "execution and collapses throughput. Use JSR223 with Groovy instead."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("performance", "deprecated")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        count = ir.parsed_data.get("beanshell_count", 0)
        if count > 0:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=f"Found {count} BeanShell element(s). Replace with JSR223 (Groovy) for correct behaviour under load.",
                location=Location(element_path="/jmeterTestPlan"),
                suggestion="Replace BeanShellSampler/PreProcessor/PostProcessor with JSR223Sampler/PreProcessor/PostProcessor using Groovy.",
                fix_example='<JSR223Sampler guiclass="TestBeanGUI" testname="JSR223 Sampler">\n  <stringProp name="scriptLanguage">groovy</stringProp>\n  <stringProp name="script">// your Groovy script here</stringProp>\n</JSR223Sampler>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        for old_tag, (new_tag, gui) in _BEANSHELL_TO_JSR223.items():
            for elem in root.findall(f".//{old_tag}"):
                parent = elem.getparent()
                if parent is None:
                    continue
                idx = list(parent).index(elem)
                new_elem = copy.deepcopy(elem)
                new_elem.tag = new_tag
                new_elem.set("guiclass", gui)
                new_elem.set("testclass", new_tag)
                # Add/update scriptLanguage property
                lang_el = new_elem.find("./stringProp[@name='scriptLanguage']")
                if lang_el is None:
                    lang_el = etree.SubElement(new_elem, "stringProp")
                    lang_el.set("name", "scriptLanguage")
                lang_el.text = "groovy"
                # Rename BeanShell.query to script if present
                script_el = new_elem.find("./stringProp[@name='BeanShell.query']")
                if script_el is not None:
                    script_el.set("name", "script")
                parent.remove(elem)
                parent.insert(idx, new_elem)
                changed = True
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX015ZeroThinkTime(BaseRule):
    rule_id = "JMX015"
    name = "ZeroThinkTime"
    description = (
        "ConstantTimer with delay <= 50 ms detected. A timer that fires in near-zero "
        "time gives false confidence that think time is configured while still "
        "hammering the server unrealistically."
    )
    severity = Severity.ERROR
    frameworks = [Framework.JMETER]
    tags = ("think-time", "realism")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        delays = ir.parsed_data.get("constant_timer_delays", [])
        bad = [d for d in delays if d <= 50]
        if bad:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=f"ConstantTimer delay of {bad[0]} ms is effectively zero. Set to at least 1000 ms to simulate realistic think time.",
                location=Location(element_path="/jmeterTestPlan//ConstantTimer"),
                suggestion="Set ConstantTimer.delay to at least 1000 (1 second) to simulate realistic user think time.",
                fix_example='<stringProp name="ConstantTimer.delay">1000</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        for ct in root.findall(".//ConstantTimer"):
            delay_el = ct.find("./stringProp[@name='ConstantTimer.delay']")
            if delay_el is not None and delay_el.text and "${" not in delay_el.text:
                try:
                    if int(delay_el.text.strip()) <= 50:
                        delay_el.text = "1000"
                        changed = True
                except ValueError:
                    pass
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX016MissingCorrelation(BaseRule):
    rule_id = "JMX016"
    name = "MissingCorrelation"
    description = (
        "Request bodies contain what appear to be hardcoded session tokens (JWT or UUID) "
        "but no extractors (RegexExtractor, JSONPathExtractor, etc.) are configured. "
        "Hardcoded tokens fail under concurrent users and cause silent 401/403 failures."
    )
    severity = Severity.ERROR
    frameworks = [Framework.JMETER]
    tags = ("correlation", "correctness", "parameterization")
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("has_potential_dynamic_values") and ir.parsed_data.get("extractor_count", 0) == 0:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=(
                    "Request bodies contain hardcoded tokens (JWT or UUID) with no extractors configured. "
                    "Concurrent users will all send the same token, causing authentication failures."
                ),
                location=Location(element_path="/jmeterTestPlan"),
                suggestion="Add a RegexExtractor or JSONPathExtractor after your login request to capture the session token dynamically.",
                fix_example='<RegexExtractor guiclass="RegexExtractorGui" testname="Extract Token">\n  <stringProp name="RegexExtractor.useHeaders">false</stringProp>\n  <stringProp name="RegexExtractor.refname">TOKEN</stringProp>\n  <stringProp name="RegexExtractor.regex">"token":"([^"]+)"</stringProp>\n  <stringProp name="RegexExtractor.default"></stringProp>\n</RegexExtractor>',
            )]
        return []


@RuleRegistry.register
class JMX017MissingConnectionTimeout(BaseRule):
    rule_id = "JMX017"
    name = "MissingConnectionTimeout"
    description = (
        "HTTP Request Defaults has no connection timeout (or timeout is 0). "
        "Without a timeout, a connection hang blocks a VU thread indefinitely; "
        "under load, all threads pile up and the test produces no results."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("resilience", "configuration")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) == 0:
            return []
        ct = ir.parsed_data.get("connection_timeout")
        if ct is None:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message="No connection timeout configured in HTTP Request Defaults. JMeter default is unlimited — a hung connection blocks the VU thread forever.",
                location=Location(element_path="/jmeterTestPlan//ConfigTestElement"),
                suggestion="Add connect_timeout to HTTP Request Defaults (e.g. 5000 ms).",
                fix_example='<stringProp name="HTTPSampler.connect_timeout">5000</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        config_elements = root.findall(".//ConfigTestElement")
        for cte in config_elements:
            ct_el = cte.find("./stringProp[@name='HTTPSampler.connect_timeout']")
            if ct_el is None:
                prop = etree.SubElement(cte, "stringProp")
                prop.set("name", "HTTPSampler.connect_timeout")
                prop.text = "5000"
                changed = True
            elif not ct_el.text or ct_el.text.strip() in ("", "0"):
                ct_el.text = "5000"
                changed = True
        if not changed and not config_elements:
            # No ConfigTestElement exists — create a complete one (including the
            # required elementProp for HTTPsampler.Arguments) to avoid the
            # HttpDefaultsGui NullPointerException when JMeter loads the file.
            if not _insert_into_tg_hashtree(root, _make_http_defaults(connect_timeout="5000")):
                return None
            changed = True
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX018MissingResponseTimeout(BaseRule):
    rule_id = "JMX018"
    name = "MissingResponseTimeout"
    description = (
        "HTTP Request Defaults has no response timeout (or timeout is 0). "
        "Without a timeout, slow responses block VU threads indefinitely."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("resilience", "configuration")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) == 0:
            return []
        rt = ir.parsed_data.get("response_timeout")
        if rt is None:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message="No response timeout configured in HTTP Request Defaults. Slow responses will block VU threads indefinitely.",
                location=Location(element_path="/jmeterTestPlan//ConfigTestElement"),
                suggestion="Add response_timeout to HTTP Request Defaults (e.g. 30000 ms).",
                fix_example='<stringProp name="HTTPSampler.response_timeout">30000</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        config_elements = root.findall(".//ConfigTestElement")
        for cte in config_elements:
            rt_el = cte.find("./stringProp[@name='HTTPSampler.response_timeout']")
            if rt_el is None:
                prop = etree.SubElement(cte, "stringProp")
                prop.set("name", "HTTPSampler.response_timeout")
                prop.text = "30000"
                changed = True
            elif not rt_el.text or rt_el.text.strip() in ("", "0"):
                rt_el.text = "30000"
                changed = True
        if not changed and not config_elements:
            # No ConfigTestElement exists — create a complete one (including the
            # required elementProp for HTTPsampler.Arguments) to avoid the
            # HttpDefaultsGui NullPointerException when JMeter loads the file.
            if not _insert_into_tg_hashtree(root, _make_http_defaults(response_timeout="30000")):
                return None
            changed = True
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX019InfiniteLoop(BaseRule):
    rule_id = "JMX019"
    name = "InfiniteLoop"
    description = (
        "Thread Group loop count is -1 (infinite) without a scheduler enabled. "
        "This test will run forever unless manually stopped."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("configuration", "lifecycle")
    fixable = True
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("has_infinite_loop"):
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message="Thread Group has infinite loop count (-1) with no scheduler. The test will run forever unless stopped manually.",
                location=Location(element_path="/jmeterTestPlan//ThreadGroup"),
                suggestion="Either set a finite loop count or enable the Thread Group scheduler with a duration limit.",
                fix_example='<!-- Option 1: Set finite loop count -->\n<stringProp name="LoopController.loops">10</stringProp>\n\n<!-- Option 2: Enable scheduler -->\n<boolProp name="ThreadGroup.scheduler">true</boolProp>\n<stringProp name="ThreadGroup.duration">300</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        for tg in root.findall(".//ThreadGroup"):
            scheduler_el = tg.find("./boolProp[@name='ThreadGroup.scheduler']")
            if scheduler_el is not None and (scheduler_el.text or "").lower() == "true":
                continue
            # Check for infinite loop (intProp or elementProp LoopController)
            # Try direct intProp path
            loops_el = tg.find("./intProp[@name='LoopController.loops']")
            if loops_el is None:
                # Try elementProp path (stringProp variant)
                for ep in tg.findall(".//elementProp[@elementType='LoopController']"):
                    loops_el = ep.find("./stringProp[@name='LoopController.loops']")
                    if loops_el is None:
                        loops_el = ep.find("./intProp[@name='LoopController.loops']")
                    if loops_el is not None:
                        break
            if loops_el is not None and (loops_el.text or "").strip() == "-1":
                if scheduler_el is None:
                    scheduler_el = etree.SubElement(tg, "boolProp")
                    scheduler_el.set("name", "ThreadGroup.scheduler")
                scheduler_el.text = "true"
                duration_el = tg.find("./stringProp[@name='ThreadGroup.duration']")
                if duration_el is None:
                    duration_el = etree.SubElement(tg, "stringProp")
                    duration_el.set("name", "ThreadGroup.duration")
                if not duration_el.text or duration_el.text.strip() in ("", "0"):
                    duration_el.text = "300"
                changed = True
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX020HardcodedPort(BaseRule):
    rule_id = "JMX020"
    name = "HardcodedPort"
    description = (
        "HTTP sampler uses a hardcoded non-standard port (not 80/443). "
        "Hardcoded ports prevent pointing the test at different environments."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("parameterization", "portability")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        ports = ir.parsed_data.get("hardcoded_ports", [])
        if ports:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=f"Sampler uses hardcoded port {ports[0]}. Use HTTP Request Defaults or a variable like ${{PORT}}.",
                location=Location(element_path="/jmeterTestPlan//HTTPSamplerProxy"),
                suggestion="Move the port to HTTP Request Defaults or parameterise with ${PORT}.",
                fix_example='<stringProp name="HTTPSampler.port">${PORT}</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        changed = False
        port_vars: dict[int, str] = {}  # port number -> variable name
        for hs in root.findall(".//HTTPSamplerProxy"):
            port_el = hs.find("./stringProp[@name='HTTPSampler.port']")
            if port_el is not None and port_el.text and "${" not in port_el.text:
                try:
                    port = int(port_el.text.strip())
                    if port not in (0, 80, 443):
                        if port not in port_vars:
                            idx = len(port_vars) + 1
                            port_vars[port] = "PORT" if idx == 1 else f"PORT_{idx}"
                        port_el.text = "${" + port_vars[port] + "}"
                        changed = True
                except ValueError:
                    pass
        return _serialize_jmx(root) if changed else None


@RuleRegistry.register
class JMX021MissingContentTypeHeader(BaseRule):
    rule_id = "JMX021"
    name = "MissingContentTypeHeader"
    description = (
        "POST/PUT/PATCH sampler(s) detected but no Content-Type header is configured "
        "in any Header Manager. Missing Content-Type causes many frameworks to reject "
        "or misparse request bodies."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("http", "correctness")
    fixable = True
    tier = "pro"

    def check(self, ir: ScriptIR) -> list[Violation]:
        count = ir.parsed_data.get("post_samplers_without_content_type", 0)
        if count > 0:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=f"{count} POST/PUT/PATCH sampler(s) found with no Content-Type header configured.",
                location=Location(element_path="/jmeterTestPlan//HeaderManager"),
                suggestion="Add Content-Type to your HTTP Header Manager (e.g. application/json).",
                fix_example='<elementProp name="Content-Type" elementType="Header">\n  <stringProp name="Header.name">Content-Type</stringProp>\n  <stringProp name="Header.value">application/json</stringProp>\n</elementProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        hm = root.find(".//HeaderManager")
        if hm is None:
            # No HeaderManager exists — create one in the thread group hashTree
            tg_ht = _find_thread_group_hashtree(root)
            if tg_ht is None:
                return None
            hm = etree.Element("HeaderManager")
            hm.set("guiclass", "HeaderPanel")
            hm.set("testclass", "HeaderManager")
            hm.set("testname", "HTTP Header Manager")
            hm.set("enabled", "true")
            tg_ht.insert(0, etree.Element("hashTree"))
            tg_ht.insert(0, hm)
        # Check if Content-Type already exists
        coll = hm.find("./collectionProp[@name='HeaderManager.headers']")
        if coll is None:
            coll = etree.SubElement(hm, "collectionProp")
            coll.set("name", "HeaderManager.headers")
        for entry in coll.findall("./elementProp[@elementType='Header']"):
            name_el = entry.find("./stringProp[@name='Header.name']")
            if name_el is not None and (name_el.text or "").lower() == "content-type":
                return None  # Already present
        entry = etree.SubElement(coll, "elementProp")
        entry.set("name", "Content-Type")
        entry.set("elementType", "Header")
        name_prop = etree.SubElement(entry, "stringProp")
        name_prop.set("name", "Header.name")
        name_prop.text = "Content-Type"
        val_prop = etree.SubElement(entry, "stringProp")
        val_prop.set("name", "Header.value")
        val_prop.text = "application/json"
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX022SizeAssertionMissing(BaseRule):
    rule_id = "JMX022"
    name = "SizeAssertionMissing"
    description = (
        "No Size Assertion found. Size assertions catch truncated or stub responses "
        "(e.g. 0-byte body from a WAF after auth failure) that response code assertions miss."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("assertions", "correctness")
    fixable = True
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("http_sampler_count", 0) < 3:
            return []
        if not ir.parsed_data.get("has_size_assertion"):
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message="No Size Assertion found. Consider adding one to catch truncated or empty responses.",
                location=Location(element_path="/jmeterTestPlan"),
                suggestion="Add a Size Assertion to verify responses are not empty or unexpectedly small.",
                fix_example='<SizeAssertion guiclass="SizeAssertionGui" testname="Size Assertion">\n  <stringProp name="Assertion.test_type">2</stringProp>\n  <stringProp name="SizeAssertion.size">1</stringProp>\n</SizeAssertion>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//SizeAssertion"):
            return None
        elem = etree.Element("SizeAssertion")
        elem.set("guiclass", "SizeAssertionGui")
        elem.set("testclass", "SizeAssertion")
        elem.set("testname", "Size Assertion")
        elem.set("enabled", "true")
        # test_type 2 = NOT_EQUAL (body size != 0 means non-empty); intProp required.
        test_type = etree.SubElement(elem, "intProp")
        test_type.set("name", "Assertion.test_type")
        test_type.text = "2"
        # SizeAssertion.size must be longProp per JMeter's SizeAssertionGui schema.
        size = etree.SubElement(elem, "longProp")
        size.set("name", "SizeAssertion.size")
        size.text = "1"
        if not _insert_into_tg_hashtree(root, elem):
            return None
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX023NoBackendListener(BaseRule):
    rule_id = "JMX023"
    name = "NoBackendListener"
    description = (
        "No BackendListener found. BackendListener (InfluxDB, Graphite) enables "
        "real-time results streaming, essential for long-duration CI runs."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("observability", "ci-integration")
    fixable = True

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("sampler_count", 0) == 0:
            return []
        if ir.parsed_data.get("backend_listener_count", 0) == 0:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message="No BackendListener found. Add one to stream results to InfluxDB or Graphite for real-time dashboards.",
                location=Location(element_path="/jmeterTestPlan"),
                suggestion="Add a BackendListener configured for InfluxDB or Graphite for CI/CD observability.",
                fix_example='<BackendListener guiclass="BackendListenerGui" testname="influxdb">\n  <elementProp name="arguments" elementType="Arguments">\n    <collectionProp name="Arguments.arguments"/>\n  </elementProp>\n  <stringProp name="classname">org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient</stringProp>\n</BackendListener>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        if root.findall(".//BackendListener"):
            return None
        outer_ht = root.find("./hashTree")
        if outer_ht is None:
            return None
        inner_ht = outer_ht.find("./hashTree")
        if inner_ht is None:
            return None
        elem = etree.Element("BackendListener")
        elem.set("guiclass", "BackendListenerGui")
        elem.set("testclass", "BackendListener")
        elem.set("testname", "InfluxDB Backend Listener")
        elem.set("enabled", "true")
        args_ep = etree.SubElement(elem, "elementProp")
        args_ep.set("name", "arguments")
        args_ep.set("elementType", "Arguments")
        coll = etree.SubElement(args_ep, "collectionProp")
        coll.set("name", "Arguments.arguments")
        classname = etree.SubElement(elem, "stringProp")
        classname.set("name", "classname")
        classname.text = "org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient"
        inner_ht.append(elem)
        inner_ht.append(etree.Element("hashTree"))
        return _serialize_jmx(root)


@RuleRegistry.register
class JMX024MultipleThreadGroupsNoSync(BaseRule):
    rule_id = "JMX024"
    name = "MultipleThreadGroupsNoSync"
    description = (
        "Multiple Thread Groups found but no SetupThreadGroup. "
        "Multiple thread groups run simultaneously and their metrics are merged in "
        "default listeners, producing potentially misleading aggregate results."
    )
    severity = Severity.INFO
    frameworks = [Framework.JMETER]
    tags = ("structure", "reporting")
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        if ir.parsed_data.get("thread_group_count", 0) < 2:
            return []
        if ir.parsed_data.get("has_setup_thread_group", False):
            return []
        return [Violation(
            rule_id=self.rule_id,
            severity=self.severity,
            message=f"Found {ir.parsed_data.get('thread_group_count', 0)} Thread Groups with no SetupThreadGroup. Ensure your listeners are configured to report per-thread-group metrics.",
            location=Location(element_path="/jmeterTestPlan"),
            suggestion="Add a SetupThreadGroup for test initialisation, or use a separate thread group controller. Use 'Generate parent sample' in Transaction Controllers for per-group reporting.",
            fix_example='<SetupThreadGroup guiclass="SetupThreadGroupGui" testname="setUp Thread Group">\n  <!-- Initialisation steps -->\n</SetupThreadGroup>',
        )]


@RuleRegistry.register
class JMX025RegexExtractorGreedyMatch(BaseRule):
    rule_id = "JMX025"
    name = "RegexExtractorGreedyMatch"
    description = (
        "RegexExtractor uses a greedy match pattern (.+ or .*) without '?'. "
        "Greedy extractors grab the maximum possible match and break under slightly "
        "different response sizes, causing false passes in subsequent requests."
    )
    severity = Severity.WARNING
    frameworks = [Framework.JMETER]
    tags = ("correlation", "correctness")
    fixable = True
    tier = "team"

    def check(self, ir: ScriptIR) -> list[Violation]:
        greedy = ir.parsed_data.get("greedy_regex_extractors", [])
        if greedy:
            return [Violation(
                rule_id=self.rule_id,
                severity=self.severity,
                message=f"RegexExtractor uses greedy pattern '{greedy[0]}'. Use '.+?' or '.*?' (non-greedy) instead.",
                location=Location(element_path="/jmeterTestPlan//RegexExtractor"),
                suggestion="Replace (.+) with (.+?) and (.*) with (.*?) to use non-greedy matching.",
                fix_example='<!-- Greedy (bad): -->\n<stringProp name="RegexExtractor.regex">"token":"(.+)"</stringProp>\n\n<!-- Non-greedy (good): -->\n<stringProp name="RegexExtractor.regex">"token":"(.+?)"</stringProp>',
            )]
        return []

    def apply_fix(self, ir: ScriptIR) -> str | None:
        root = _parse_jmx(ir.raw_content)
        if root is None:
            return None
        _GREEDY = re.compile(r"\(\.([+*])\)")
        changed = False
        for re_el in root.findall(".//RegexExtractor"):
            regex_prop = re_el.find("./stringProp[@name='RegexExtractor.regex']")
            if regex_prop is not None and regex_prop.text and _GREEDY.search(regex_prop.text):
                new_text = _GREEDY.sub(r"(.\1?)", regex_prop.text)
                if new_text != regex_prop.text:
                    regex_prop.text = new_text
                    changed = True
        return _serialize_jmx(root) if changed else None
