"""Structure rules: STR002, STR003, STR004, STR005, STR006."""

import ast
from collections.abc import Iterator
from typing import Final

from sergey.rules import base

_MAX_DEPTH: Final[int] = 4

_SCOPE_TYPES: Final[tuple[type[ast.AST], ...]] = (
    ast.FunctionDef,
    ast.AsyncFunctionDef,
    ast.ClassDef,
    ast.Lambda,
)

# ast.TryStar (try/except*) was added in Python 3.11.
_TRY_STAR: Final[tuple[type[ast.AST], ...]] = (
    (ast.TryStar,) if hasattr(ast, "TryStar") else ()
)

_OTHER_NESTING: Final[tuple[type[ast.AST], ...]] = (
    ast.For,
    ast.AsyncFor,
    ast.While,
    ast.With,
    ast.AsyncWith,
    ast.Try,
    ast.Match,
    *_TRY_STAR,
)


def _make_diagnostic(node: ast.stmt, depth: int) -> base.Diagnostic:
    return base.Diagnostic(
        rule_id="STR002",
        message=(
            f"Nesting depth {depth} exceeds the maximum of {_MAX_DEPTH};"
            f" extract logic to reduce nesting"
        ),
        line=node.lineno,
        col=node.col_offset,
        end_line=node.end_lineno or node.lineno,
        end_col=node.end_col_offset or node.col_offset,
        severity=base.Severity.WARNING,
    )


def _dispatch(node: ast.AST, depth: int, diagnostics: list[base.Diagnostic]) -> None:
    """Dispatch a single AST node, tracking control-flow nesting depth."""
    if isinstance(node, _SCOPE_TYPES):
        # Function, class, or lambda bodies reset the depth counter.
        for child in ast.iter_child_nodes(node):
            _dispatch(child, 0, diagnostics)
    elif isinstance(node, ast.If):
        _enter_if(node, depth, diagnostics, is_elif=False)
    elif isinstance(node, _OTHER_NESTING):
        _enter(node, depth, diagnostics)
    else:
        for child in ast.iter_child_nodes(node):
            _dispatch(child, depth, diagnostics)


def _enter(node: ast.AST, depth: int, diagnostics: list[base.Diagnostic]) -> None:
    """Enter a nesting construct, incrementing depth and emitting if over limit."""
    new_depth = depth + 1
    if new_depth > _MAX_DEPTH and depth == _MAX_DEPTH:
        diagnostics.append(_make_diagnostic(node, new_depth))  # type: ignore[arg-type]
    for child in ast.iter_child_nodes(node):
        _dispatch(child, new_depth, diagnostics)


def _enter_if(
    node: ast.If,
    depth: int,
    diagnostics: list[base.Diagnostic],
    *,
    is_elif: bool,
) -> None:
    """Enter an If node, treating elif branches at the same depth as the if.

    This prevents elif chains from being penalised as extra nesting levels.
    Only the leading `if` emits a diagnostic when over the limit.
    """
    new_depth = depth + 1
    if not is_elif and new_depth > _MAX_DEPTH and depth == _MAX_DEPTH:
        diagnostics.append(_make_diagnostic(node, new_depth))

    _dispatch(node.test, new_depth, diagnostics)
    for stmt in node.body:
        _dispatch(stmt, new_depth, diagnostics)

    if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
        # elif: use the same base depth so it counts as the same level.
        _enter_if(node.orelse[0], depth, diagnostics, is_elif=True)
    else:
        for stmt in node.orelse:
            _dispatch(stmt, new_depth, diagnostics)


class STR002(base.Rule):
    """Flag control-flow blocks nested deeper than the maximum allowed depth.

    The nesting constructs counted are: if/elif/else, for, while, with, try,
    and match. elif branches are treated as the same depth as their parent if,
    so an if/elif/else chain only adds one level regardless of the number of
    branches. Function, class, and lambda definitions reset the nesting counter,
    so nested functions are evaluated independently.

    Default maximum depth: 4.

    Allowed:
        def foo():
            for item in items:          # depth 1
                for sub in item.subs:   # depth 2
                    if sub.active:      # depth 3
                        with lock:      # depth 4 — OK

    Flagged:
        def foo():
            for item in items:          # depth 1
                for sub in item.subs:   # depth 2
                    if sub.active:      # depth 3
                        with lock:      # depth 4
                            if flag:    # depth 5 — flagged
    """

    def check(self, tree: ast.Module, source: str) -> list[base.Diagnostic]:
        """Return a diagnostic for each block that exceeds the maximum nesting depth."""
        diagnostics: list[base.Diagnostic] = []
        try:
            for child in ast.iter_child_nodes(tree):
                _dispatch(child, 0, diagnostics)
        except Exception:  # noqa: BLE001, S110
            pass
        return diagnostics


_DEFAULT_MAX_TRY_BODY: Final[int] = 4


def _child_stmt_lists(node: ast.AST) -> list[list[ast.stmt]]:
    """Return child statement lists of a compound statement node.

    Used by ``_count_stmts`` to recurse into nested control flow without
    crossing function or class scope boundaries.
    """
    if isinstance(node, (ast.If, ast.For, ast.AsyncFor, ast.While)):
        return [node.body, node.orelse]
    if isinstance(node, (ast.With, ast.AsyncWith)):
        return [node.body]
    if isinstance(node, ast.Try):
        bodies: list[list[ast.stmt]] = [node.body, node.orelse, node.finalbody]
        bodies.extend(handler.body for handler in node.handlers)
        return bodies
    if hasattr(ast, "TryStar") and isinstance(node, ast.TryStar):
        ts_bodies: list[list[ast.stmt]] = [node.body, node.orelse, node.finalbody]
        ts_bodies.extend(handler.body for handler in node.handlers)
        return ts_bodies
    if isinstance(node, ast.Match):
        return [case.body for case in node.cases]
    return []


def _count_stmts(stmts: list[ast.stmt]) -> int:
    """Count statements recursively, not descending into new scopes."""
    total = 0
    for stmt in stmts:
        total += 1
        if isinstance(stmt, _SCOPE_TYPES):
            continue
        for child_list in _child_stmt_lists(stmt):
            total += _count_stmts(child_list)
    return total


class STR003(base.Rule):
    """Flag try blocks whose body contains too many statements.

    A large try body makes it hard to identify which operation can raise.
    Extract logic into helper functions to keep the guarded scope minimal.

    Statements are counted recursively: an ``if`` with three branches
    containing two statements each contributes 7 to the total (1 for the
    ``if`` itself plus 6 for its contents). Nested functions and classes
    reset the count and are not included.

    Only the ``try:`` body is counted — ``except`` and ``finally`` blocks
    are not subject to this rule.

    Default maximum: 4 statements (5 or more is flagged).

    Allowed:
        try:
            result = fetch(url)
        except RequestError:
            handle()

    Flagged (default threshold):
        try:
            for item in items:    # 1 (for) + 4 (body) = 5 — flagged
                a = step_one()
                b = step_two()
                c = step_three()
                d = step_four()
        except Exception:
            pass
    """

    def __init__(self, max_body_stmts: int = _DEFAULT_MAX_TRY_BODY) -> None:
        """Initialise with the maximum allowed number of statements in a try body.

        Args:
            max_body_stmts: Maximum number of statements allowed in the try body
                before a diagnostic is emitted.
        """
        self._max_body_stmts = max_body_stmts

    def configure(self, options: dict[str, int | str | bool]) -> base.Rule:
        """Return a new STR003 with options applied.

        Args:
            options: Recognises ``max_body_stmts`` (int).

        Returns:
            A new STR003 instance with the configured threshold, or self if
            the option is absent or not an integer.
        """
        max_body_stmts = options.get("max_body_stmts", self._max_body_stmts)
        if isinstance(max_body_stmts, int):
            return STR003(max_body_stmts=max_body_stmts)
        return self

    def _check_try_node(self, node: ast.Try) -> list[base.Diagnostic]:
        """Return a diagnostic if *node*'s body exceeds the statement limit."""
        stmt_count = _count_stmts(node.body)
        if stmt_count <= self._max_body_stmts:
            return []
        return [
            base.Diagnostic(
                rule_id="STR003",
                message=(
                    f"try body has {stmt_count} statements"
                    f" (maximum {self._max_body_stmts});"
                    f" extract logic to narrow the guarded scope"
                ),
                line=node.lineno,
                col=node.col_offset,
                end_line=node.end_lineno or node.lineno,
                end_col=node.end_col_offset or node.col_offset,
                severity=base.Severity.WARNING,
            )
        ]

    def check(self, tree: ast.Module, source: str) -> list[base.Diagnostic]:
        """Return a diagnostic for every try body exceeding the statement limit."""
        diagnostics: list[base.Diagnostic] = []
        try:
            for node in ast.walk(tree):
                if isinstance(node, ast.Try):
                    diagnostics.extend(self._check_try_node(node))
        except Exception:  # noqa: BLE001, S110
            pass
        return diagnostics


# ---------------------------------------------------------------------------
# STR004 — prefer tuples/frozensets for unmodified lists/sets
# ---------------------------------------------------------------------------

_LIST_MUTATING_METHODS: Final[frozenset[str]] = frozenset(
    {
        "append",
        "clear",
        "extend",
        "insert",
        "pop",
        "remove",
        "reverse",
        "sort",
    }
)

_SET_MUTATING_METHODS: Final[frozenset[str]] = frozenset(
    {
        "add",
        "clear",
        "difference_update",
        "discard",
        "intersection_update",
        "pop",
        "remove",
        "symmetric_difference_update",
        "update",
    }
)


def _iter_scope(node: ast.AST) -> Iterator[ast.AST]:
    """Yield all descendant nodes within the current function scope.

    Does not descend into nested function, class, or lambda definitions.
    """
    for child in ast.iter_child_nodes(node):
        if isinstance(child, _SCOPE_TYPES):
            continue
        yield child
        yield from _iter_scope(child)


def _has_name_ref(node: ast.AST, name: str) -> bool:
    """Return True if *node* contains a reference to *name*."""
    if isinstance(node, ast.Name) and node.id == name:
        return True
    return any(_has_name_ref(child, name) for child in ast.iter_child_nodes(node))


def _is_name_used_in_nested_scope(node: ast.AST, name: str) -> bool:
    """Return True if *name* is referenced inside a nested scope under *node*."""
    for child in ast.iter_child_nodes(node):
        if isinstance(child, _SCOPE_TYPES):
            for inner in ast.walk(child):
                if isinstance(inner, ast.Name) and inner.id == name:
                    return True
        elif _is_name_used_in_nested_scope(child, name):
            return True
    return False


def _has_name_in_target(target: ast.AST, name: str) -> bool:
    """Return True if *target* binds *name* (handles tuple/list unpacking)."""
    if isinstance(target, ast.Name):
        return target.id == name
    if isinstance(target, (ast.Tuple, ast.List)):
        return any(_has_name_in_target(elt, name) for elt in target.elts)
    if isinstance(target, ast.Starred):
        return _has_name_in_target(target.value, name)
    return False


def _is_mutated(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
    mutating_methods: frozenset[str],
) -> bool:
    """Return True if the object bound to *name* is mutated in-place."""
    for node in _iter_scope(func):
        # Mutating method calls: name.append(...), name.add(...), etc.
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and isinstance(node.func.value, ast.Name)
            and node.func.value.id == name
            and node.func.attr in mutating_methods
        ):
            return True
        # Augmented assignment: name += [...] / name |= {...}
        if (
            isinstance(node, ast.AugAssign)
            and isinstance(node.target, ast.Name)
            and node.target.id == name
        ):
            return True
        # Subscript assignment: name[i] = value
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if (
                    isinstance(target, ast.Subscript)
                    and isinstance(target.value, ast.Name)
                    and target.value.id == name
                ):
                    return True
        # Subscript deletion: del name[i]
        if isinstance(node, ast.Delete):
            for target in node.targets:
                if (
                    isinstance(target, ast.Subscript)
                    and isinstance(target.value, ast.Name)
                    and target.value.id == name
                ):
                    return True
    return False


def _is_in_function_output(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
) -> bool:
    """Return True if *name* appears in a return or yield expression."""
    for node in _iter_scope(func):
        if (
            isinstance(node, ast.Return)
            and node.value is not None
            and _has_name_ref(node.value, name)
        ):
            return True
        if (
            isinstance(node, (ast.Yield, ast.YieldFrom))
            and node.value is not None
            and _has_name_ref(node.value, name)
        ):
            return True
    return False


def _has_name_binding(node: ast.AST, name: str) -> bool:
    """Return True if *node* binds *name* through any form of assignment."""
    if isinstance(node, ast.Assign):
        return any(_has_name_in_target(target, name) for target in node.targets)
    if isinstance(node, ast.AnnAssign) and node.value is not None:
        return _has_name_in_target(node.target, name)
    if isinstance(node, (ast.For, ast.AsyncFor)):
        return _has_name_in_target(node.target, name)
    if isinstance(node, (ast.With, ast.AsyncWith)):
        return any(
            item.optional_vars is not None
            and _has_name_in_target(item.optional_vars, name)
            for item in node.items
        )
    if isinstance(node, ast.NamedExpr):
        return node.target.id == name
    return False


def _is_name_rebound(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
    creation: ast.stmt,
) -> bool:
    """Return True if *name* is assigned more than once (ignoring *creation*)."""
    return any(
        _has_name_binding(node, name)
        for node in _iter_scope(func)
        if node is not creation
    )


def _has_global_or_nonlocal(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
) -> bool:
    """Return True if *name* is declared ``global`` or ``nonlocal``."""
    for node in _iter_scope(func):
        if isinstance(node, (ast.Global, ast.Nonlocal)) and name in node.names:
            return True
    return False


def _can_escape(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
) -> bool:
    """Return True if the object may escape via attribute or subscript storage."""
    for node in _iter_scope(func):
        if not isinstance(node, ast.Assign):
            continue
        if not _has_name_ref(node.value, name):
            continue
        for target in node.targets:
            if isinstance(target, (ast.Attribute, ast.Subscript)):
                return True
    return False


_MUTABLE_LITERAL_TYPES: Final[tuple[type[ast.expr], ...]] = (ast.List, ast.Set)


def _collect_literal_assignments(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
) -> list[tuple[str, ast.stmt, type[ast.expr]]]:
    """Return ``(name, node, literal_type)`` triples for mutable literals."""
    candidates: list[tuple[str, ast.stmt, type[ast.expr]]] = []
    for node in _iter_scope(func):
        if (
            isinstance(node, ast.Assign)
            and isinstance(node.value, _MUTABLE_LITERAL_TYPES)
            and len(node.targets) == 1
            and isinstance(node.targets[0], ast.Name)
        ):
            candidates.append((node.targets[0].id, node, type(node.value)))
        elif (
            isinstance(node, ast.AnnAssign)
            and node.value is not None
            and isinstance(node.value, _MUTABLE_LITERAL_TYPES)
            and isinstance(node.target, ast.Name)
        ):
            candidates.append((node.target.id, node, type(node.value)))
    return candidates


def _should_skip(
    func: ast.FunctionDef | ast.AsyncFunctionDef,
    name: str,
    assign_node: ast.stmt,
    mutating_methods: frozenset[str],
) -> bool:
    """Return True if the mutable literal should *not* be flagged."""
    return (
        _has_global_or_nonlocal(func, name)
        or _is_name_rebound(func, name, assign_node)
        or _is_mutated(func, name, mutating_methods)
        or _is_in_function_output(func, name)
        or _is_name_used_in_nested_scope(func, name)
        or _can_escape(func, name)
    )


_IMMUTABLE_SUGGESTION: Final[dict[type[ast.expr], tuple[str, str, frozenset[str]]]] = {
    ast.List: ("List", "tuple", _LIST_MUTATING_METHODS),
    ast.Set: ("Set", "frozenset", _SET_MUTATING_METHODS),
}


class STR004(base.Rule):
    """Flag list/set literals in functions that are never modified and not returned.

    When a list or set is created inside a function body and is never mutated
    and is not part of the function output (``return`` / ``yield``), an
    immutable alternative should be used instead — ``tuple`` for lists and
    ``frozenset`` for sets.

    Recognised list mutations: ``append``, ``extend``, ``insert``, ``pop``,
    ``remove``, ``clear``, ``sort``, ``reverse``, augmented assignment, and
    item assignment/deletion.

    Recognised set mutations: ``add``, ``update``, ``discard``, ``pop``,
    ``remove``, ``clear``, ``difference_update``, ``intersection_update``,
    ``symmetric_difference_update``, and augmented assignment.

    Only plain literals (``[...]`` / ``{...}``) are checked; constructor
    calls and comprehensions are not covered.

    Allowed:
        def build():
            items = []
            items.append(1)
            return items

        def configure():
            seen = {1, 2}
            seen.add(3)
            return seen

    Flagged:
        def process():
            colors = ["red", "green", "blue"]
            for color in colors:
                print(color)

        def validate(value):
            allowed = {1, 2, 3}
            return value in allowed
    """

    def check(self, tree: ast.Module, source: str) -> list[base.Diagnostic]:
        """Return a diagnostic for each unmodified mutable literal."""
        diagnostics: list[base.Diagnostic] = []
        try:
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    diagnostics.extend(self._check_function(node))
        except Exception:  # noqa: BLE001, S110
            pass
        return diagnostics

    def _check_function(
        self,
        func: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> list[base.Diagnostic]:
        """Check a single function for unmodified mutable literals."""
        candidates = _collect_literal_assignments(func)
        diagnostics: list[base.Diagnostic] = []
        for name, assign_node, literal_type in candidates:
            kind_label, suggestion, mutating_methods = _IMMUTABLE_SUGGESTION[
                literal_type
            ]
            if _should_skip(func, name, assign_node, mutating_methods):
                continue
            diagnostics.append(
                base.Diagnostic(
                    rule_id="STR004",
                    message=(
                        f"{kind_label} `{name}` is never modified;"
                        f" use a {suggestion} for immutability"
                    ),
                    line=assign_node.lineno,
                    col=assign_node.col_offset,
                    end_line=assign_node.end_lineno or assign_node.lineno,
                    end_col=assign_node.end_col_offset or assign_node.col_offset,
                    severity=base.Severity.WARNING,
                )
            )
        return diagnostics


# ---------------------------------------------------------------------------
# Shared helpers for STR005 / STR006
# ---------------------------------------------------------------------------


def _is_constant_name(name: str) -> bool:
    """Return True if name follows the SCREAMING_SNAKE_CASE constant convention.

    Dunder names (``__all__``, ``__version__``, etc.) are exempt because they
    are special module attributes with their own conventions.
    """
    if name.startswith("__") and name.endswith("__"):
        return False
    alpha = [ch for ch in name if ch.isalpha()]
    return bool(alpha) and all(ch.isupper() for ch in alpha)


def _has_final_annotation(annotation: ast.expr) -> bool:
    """Return True if annotation is or wraps ``Final`` / ``typing.Final``."""
    if isinstance(annotation, ast.Name):
        return annotation.id == "Final"
    if isinstance(annotation, ast.Attribute):
        return annotation.attr == "Final"
    if isinstance(annotation, ast.Subscript):
        return _has_final_annotation(annotation.value)
    return False


# ---------------------------------------------------------------------------
# STR005
# ---------------------------------------------------------------------------


def _check_constant_final(name: str, node: ast.stmt) -> base.Diagnostic | None:
    """Return a STR005 diagnostic if a constant name lacks a Final annotation."""
    if not _is_constant_name(name):
        return None
    return base.Diagnostic(
        rule_id="STR005",
        message=(
            f"Module-level constant `{name}` is not annotated `Final`;"
            f" use `{name}: Final = ...`"
        ),
        line=node.lineno,
        col=node.col_offset,
        end_line=node.end_lineno or node.lineno,
        end_col=node.end_col_offset or node.col_offset,
        severity=base.Severity.WARNING,
    )


def _finals_for_assign(node: ast.Assign) -> list[base.Diagnostic]:
    """Return STR005 diagnostics for each constant target in an assignment."""
    result: list[base.Diagnostic] = []
    for target in node.targets:
        if isinstance(target, ast.Name):
            diag = _check_constant_final(target.id, node)
            if diag is not None:
                result.append(diag)
    return result


def _check_module_finals(tree: ast.Module) -> list[base.Diagnostic]:
    """Return STR005 diagnostics for all unannotated constants in tree.body."""
    diagnostics: list[base.Diagnostic] = []
    for node in tree.body:
        if isinstance(node, ast.Assign):
            diagnostics.extend(_finals_for_assign(node))
        elif (
            isinstance(node, ast.AnnAssign)
            and isinstance(node.target, ast.Name)
            and node.value is not None
            and not _has_final_annotation(node.annotation)
        ):
            diag = _check_constant_final(node.target.id, node)
            if diag is not None:
                diagnostics.append(diag)
    return diagnostics


class STR005(base.Rule):
    """Flag module-level constants that are not annotated ``Final``.

    Every SCREAMING_SNAKE_CASE name assigned at module scope must carry a
    ``Final`` annotation so that static type checkers can enforce that it is
    never reassigned.  This mirrors Rust's ``const`` / Haskell's top-level
    immutable binding — making the intent of "this value never changes"
    explicit and machine-verifiable.

    Dunder names (``__all__``, ``__version__``, etc.) are exempt.  Names
    inside functions, classes, ``if`` blocks, or ``try`` blocks are not
    checked — only direct ``tree.body`` assignments.

    Allowed:
        MAX_SIZE: Final = 100
        MAX_SIZE: Final[int] = 100
        MAX_SIZE: typing.Final = 100

    Flagged:
        MAX_SIZE = 100          # plain assignment, no annotation
        MAX_SIZE: int = 100     # annotated but not Final
    """

    def check(self, tree: ast.Module, source: str) -> list[base.Diagnostic]:
        """Return a diagnostic for each constant missing a Final annotation."""
        try:
            return _check_module_finals(tree)
        except Exception:  # noqa: BLE001, S110
            pass
        return []


# ---------------------------------------------------------------------------
# STR006
# ---------------------------------------------------------------------------

_MODULE_MUTABLE_SUGGESTION: Final[dict[type[ast.expr], tuple[str, str]]] = {
    ast.List: ("list", "tuple"),
    ast.Set: ("set", "frozenset"),
}


def _check_constant_mutable(
    name: str,
    value: ast.expr,
) -> base.Diagnostic | None:
    """Return a STR006 diagnostic if a constant is assigned a mutable literal."""
    if not _is_constant_name(name):
        return None
    entry = _MODULE_MUTABLE_SUGGESTION.get(type(value))
    if entry is None:
        return None
    kind_label, suggestion = entry
    return base.Diagnostic(
        rule_id="STR006",
        message=(
            f"Module-level constant `{name}` uses a mutable `{kind_label}`"
            f" literal; use `{suggestion}` instead"
        ),
        line=value.lineno,
        col=value.col_offset,
        end_line=value.end_lineno or value.lineno,
        end_col=value.end_col_offset or value.col_offset,
        severity=base.Severity.WARNING,
    )


def _mutables_for_assign(node: ast.Assign) -> list[base.Diagnostic]:
    """Return STR006 diagnostics for each constant target in an assignment."""
    if not isinstance(node.value, tuple(_MODULE_MUTABLE_SUGGESTION)):
        return []
    result: list[base.Diagnostic] = []
    for target in node.targets:
        if isinstance(target, ast.Name):
            diag = _check_constant_mutable(target.id, node.value)
            if diag is not None:
                result.append(diag)
    return result


def _check_module_mutables(tree: ast.Module) -> list[base.Diagnostic]:
    """Return STR006 diagnostics for all mutable constant literals in tree.body."""
    diagnostics: list[base.Diagnostic] = []
    for node in tree.body:
        if isinstance(node, ast.Assign):
            diagnostics.extend(_mutables_for_assign(node))
        elif (
            isinstance(node, ast.AnnAssign)
            and node.value is not None
            and isinstance(node.value, tuple(_MODULE_MUTABLE_SUGGESTION))
            and isinstance(node.target, ast.Name)
        ):
            diag = _check_constant_mutable(node.target.id, node.value)
            if diag is not None:
                diagnostics.append(diag)
    return diagnostics


class STR006(base.Rule):
    """Flag module-level constants assigned mutable ``list`` or ``set`` literals.

    ``Final`` prevents a name from being *rebound* but does nothing to prevent
    the underlying collection from being mutated in place.  A constant list or
    set should be a ``tuple`` or ``frozenset`` so that immutability is enforced
    at the value level as well.

    Only plain literals (``[...]`` and ``{...}``) at module scope are checked.
    Dict literals, constructor calls, and comprehensions are not covered.
    Names inside functions, classes, or nested blocks are not checked — those
    are handled by STR004.

    Allowed:
        ITEMS: Final = (1, 2, 3)
        TAGS: Final = frozenset({"a", "b"})

    Flagged:
        ITEMS = [1, 2, 3]           # mutable list literal
        TAGS: Final = {"a", "b"}    # Final doesn't prevent set.add()
    """

    def check(self, tree: ast.Module, source: str) -> list[base.Diagnostic]:
        """Return a diagnostic for each constant assigned a mutable literal."""
        try:
            return _check_module_mutables(tree)
        except Exception:  # noqa: BLE001, S110
            pass
        return []
