"""
This plugin is helpful for mypy development itself.
By default, it is not enabled for mypy users.

It also can be used by plugin developers as a part of their CI checks.

It finds missing ``get_proper_type()`` call, which can lead to multiple errors.
"""

from __future__ import annotations

from typing import Callable

from mypy.checker import TypeChecker
from mypy.nodes import TypeInfo
from mypy.plugin import FunctionContext, Plugin
from mypy.subtypes import is_proper_subtype
from mypy.types import (
    AnyType,
    FunctionLike,
    Instance,
    NoneTyp,
    ProperType,
    TupleType,
    Type,
    UnionType,
    get_proper_type,
    get_proper_types,
)

class ProperTypePlugin(Plugin):
    """
    A plugin to ensure that every type is expanded before doing any special-casing.

    This solves the problem that we have hundreds of call sites like:

        if isinstance(typ, UnionType):
            ...  # special-case union

    But after introducing a new type TypeAliasType (and removing immediate expansion)
    all these became dangerous because typ may be e.g. an alias to union.
    """

    def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None:
        if fullname == "builtins.isinstance":
            return isinstance_proper_hook
        if fullname == "mypy.types.get_proper_type":
            return proper_type_hook
        if fullname == "mypy.types.get_proper_types":
            return proper_types_hook
        return None

def isinstance_proper_hook(ctx: FunctionContext) -> Type:
    if len(ctx.arg_types) != 2 or not ctx.arg_types[1]:
        return ctx.default_return_type

    right = get_proper_type(ctx.arg_types[1][0])
    for arg in ctx.arg_types[0]:
        if (
            is_improper_type(arg) or isinstance(get_proper_type(arg), AnyType)
        ) and is_dangerous_target(right):
            if is_special_target(right):
                return ctx.default_return_type
            ctx.api.fail(
                "Never apply isinstance() to unexpanded types;"
                " use mypy.types.get_proper_type() first",
                ctx.context,
            )
            ctx.api.note(  # type: ignore[attr-defined]
                "If you pass on the original type"
                " after the check, always use its unexpanded version",
                ctx.context,
            )
    return ctx.default_return_type

def is_special_target(right: ProperType) -> bool:
    """Whitelist some special cases for use in isinstance() with improper types."""
    if isinstance(right, FunctionLike) and right.is_type_obj():
        if right.type_object().fullname == "builtins.tuple":
            # Used with Union[Type, Tuple[Type, ...]].
            return True
        if right.type_object().fullname in (
            "mypy.types.Type",
            "mypy.types.ProperType",
            "mypy.types.TypeAliasType",
        ):
            # Special case: things like assert isinstance(typ, ProperType) are always OK.
            return True
        if right.type_object().fullname in (
            "mypy.types.UnboundType",
            "mypy.types.TypeVarLikeType",
            "mypy.types.TypeVarType",
            "mypy.types.UnpackType",
            "mypy.types.TypeVarTupleType",
            "mypy.types.ParamSpecType",
            "mypy.types.Parameters",
            "mypy.types.RawExpressionType",
            "mypy.types.EllipsisType",
            "mypy.types.StarType",
            "mypy.types.TypeList",
            "mypy.types.CallableArgument",
            "mypy.types.PartialType",
            "mypy.types.ErasedType",
            "mypy.types.DeletedType",
            "mypy.types.RequiredType",
            "mypy.types.ReadOnlyType",
        ):
            # Special case: these are not valid targets for a type alias and thus safe.
            # TODO: introduce a SyntheticType base to simplify this?
            return True
    elif isinstance(right, TupleType):
        return all(is_special_target(t) for t in get_proper_types(right.items))
    return False

def is_improper_type(typ: Type) -> bool:
    """Is this a type that is not a subtype of ProperType?"""
    typ = get_proper_type(typ)
    if isinstance(typ, Instance):
        info = typ.type
        return info.has_base("mypy.types.Type") and not info.has_base("mypy.types.ProperType")
    if isinstance(typ, UnionType):
        return any(is_improper_type(t) for t in typ.items)
    return False

def is_dangerous_target(typ: ProperType) -> bool:
    """Is this a dangerous target (right argument) for an isinstance() check?"""
    if isinstance(typ, TupleType):
        return any(is_dangerous_target(get_proper_type(t)) for t in typ.items)
    if isinstance(typ, FunctionLike) and typ.is_type_obj():
        return typ.type_object().has_base("mypy.types.Type")
    return False

def proper_type_hook(ctx: FunctionContext) -> Type:
    """Check if this get_proper_type() call is not redundant."""
    arg_types = ctx.arg_types[0]
    if arg_types:
        arg_type = get_proper_type(arg_types[0])
        proper_type = get_proper_type_instance(ctx)
        if is_proper_subtype(arg_type, UnionType.make_union([NoneTyp(), proper_type])):
            # Minimize amount of spurious errors from overload machinery.
            # TODO: call the hook on the overload as a whole?
            if isinstance(arg_type, (UnionType, Instance)):
                ctx.api.fail("Redundant call to get_proper_type()", ctx.context)
    return ctx.default_return_type

def proper_types_hook(ctx: FunctionContext) -> Type:
    """Check if this get_proper_types() call is not redundant."""
    arg_types = ctx.arg_types[0]
    if arg_types:
        arg_type = arg_types[0]
        proper_type = get_proper_type_instance(ctx)
        item_type = UnionType.make_union([NoneTyp(), proper_type])
        ok_type = ctx.api.named_generic_type("typing.Iterable", [item_type])
        if is_proper_subtype(arg_type, ok_type):
            ctx.api.fail("Redundant call to get_proper_types()", ctx.context)
    return ctx.default_return_type

def get_proper_type_instance(ctx: FunctionContext) -> Instance:
    checker = ctx.api
    assert isinstance(checker, TypeChecker)
    types = checker.modules["mypy.types"]
    proper_type_info = types.names["ProperType"]
    assert isinstance(proper_type_info.node, TypeInfo)
    return Instance(proper_type_info.node, [])

def plugin(version: str) -> type[ProperTypePlugin]:
    return ProperTypePlugin


from __future__ import annotations

from typing import Final, NamedTuple, Sequence, TypeVar, Union
from typing_extensions import TypeAlias as _TypeAlias

from mypy.messages import format_type
from mypy.nodes import ARG_POS, Argument, Block, ClassDef, Context, SymbolTable, TypeInfo, Var
from mypy.options import Options
from mypy.plugin import CheckerPluginInterface, FunctionContext, MethodContext, MethodSigContext
from mypy.plugins.common import add_method_to_class
from mypy.subtypes import is_subtype
from mypy.types import (
    AnyType,
    CallableType,
    FunctionLike,
    Instance,
    NoneType,
    Overloaded,
    Type,
    TypeOfAny,
    get_proper_type,
)

class SingledispatchTypeVars(NamedTuple):
    return_type: Type
    fallback: CallableType

class RegisterCallableInfo(NamedTuple):
    register_type: Type
    singledispatch_obj: Instance

SINGLEDISPATCH_TYPE: Final = "functools._SingleDispatchCallable"

SINGLEDISPATCH_REGISTER_METHOD: Final = f"{SINGLEDISPATCH_TYPE}.register"

SINGLEDISPATCH_CALLABLE_CALL_METHOD: Final = f"{SINGLEDISPATCH_TYPE}.__call__"

def get_singledispatch_info(typ: Instance) -> SingledispatchTypeVars | None:
    if len(typ.args) == 2:
        return SingledispatchTypeVars(*typ.args)  # type: ignore[arg-type]
    return None

T = TypeVar("T")

def get_first_arg(args: list[list[T]]) -> T | None:
    """Get the element that corresponds to the first argument passed to the function"""
    if args and args[0]:
        return args[0][0]
    return None

REGISTER_RETURN_CLASS: Final = "_SingleDispatchRegisterCallable"

REGISTER_CALLABLE_CALL_METHOD: Final = f"functools.{REGISTER_RETURN_CLASS}.__call__"

def make_fake_register_class_instance(
    api: CheckerPluginInterface, type_args: Sequence[Type]
) -> Instance:
    defn = ClassDef(REGISTER_RETURN_CLASS, Block([]))
    defn.fullname = f"functools.{REGISTER_RETURN_CLASS}"
    info = TypeInfo(SymbolTable(), defn, "functools")
    obj_type = api.named_generic_type("builtins.object", []).type
    info.bases = [Instance(obj_type, [])]
    info.mro = [info, obj_type]
    defn.info = info

    func_arg = Argument(Var("name"), AnyType(TypeOfAny.implementation_artifact), None, ARG_POS)
    add_method_to_class(api, defn, "__call__", [func_arg], NoneType())

    return Instance(info, type_args)

PluginContext: _TypeAlias = Union[FunctionContext, MethodContext]

def fail(ctx: PluginContext, msg: str, context: Context | None) -> None:
    """Emit an error message.

    This tries to emit an error message at the location specified by `context`, falling back to the
    location specified by `ctx.context`. This is helpful when the only context information about
    where you want to put the error message may be None (like it is for `CallableType.definition`)
    and falling back to the location of the calling function is fine."""
    # TODO: figure out if there is some more reliable way of getting context information, so this
    # function isn't necessary
    if context is not None:
        err_context = context
    else:
        err_context = ctx.context
    ctx.api.fail(msg, err_context)

def create_singledispatch_function_callback(ctx: FunctionContext) -> Type:
    """Called for functools.singledispatch"""
    func_type = get_proper_type(get_first_arg(ctx.arg_types))
    if isinstance(func_type, CallableType):
        if len(func_type.arg_kinds) < 1:
            fail(
                ctx, "Singledispatch function requires at least one argument", func_type.definition
            )
            return ctx.default_return_type

        elif not func_type.arg_kinds[0].is_positional(star=True):
            fail(
                ctx,
                "First argument to singledispatch function must be a positional argument",
                func_type.definition,
            )
            return ctx.default_return_type

        # singledispatch returns an instance of functools._SingleDispatchCallable according to
        # typeshed
        singledispatch_obj = get_proper_type(ctx.default_return_type)
        assert isinstance(singledispatch_obj, Instance)
        singledispatch_obj.args += (func_type,)

    return ctx.default_return_type

def singledispatch_register_callback(ctx: MethodContext) -> Type:
    """Called for functools._SingleDispatchCallable.register"""
    assert isinstance(ctx.type, Instance)
    # TODO: check that there's only one argument
    first_arg_type = get_proper_type(get_first_arg(ctx.arg_types))
    if isinstance(first_arg_type, (CallableType, Overloaded)) and first_arg_type.is_type_obj():
        # HACK: We received a class as an argument to register. We need to be able
        # to access the function that register is being applied to, and the typeshed definition
        # of register has it return a generic Callable, so we create a new
        # SingleDispatchRegisterCallable class, define a __call__ method, and then add a
        # plugin hook for that.

        # is_subtype doesn't work when the right type is Overloaded, so we need the
        # actual type
        register_type = first_arg_type.items[0].ret_type
        type_args = RegisterCallableInfo(register_type, ctx.type)
        register_callable = make_fake_register_class_instance(ctx.api, type_args)
        return register_callable
    elif isinstance(first_arg_type, CallableType):
        # TODO: do more checking for registered functions
        register_function(ctx, ctx.type, first_arg_type, ctx.api.options)
        # The typeshed stubs for register say that the function returned is Callable[..., T], even
        # though the function returned is the same as the one passed in. We return the type of the
        # function so that mypy can properly type check cases where the registered function is used
        # directly (instead of through singledispatch)
        return first_arg_type

    # fallback in case we don't recognize the arguments
    return ctx.default_return_type

def register_function(
    ctx: PluginContext,
    singledispatch_obj: Instance,
    func: Type,
    options: Options,
    register_arg: Type | None = None,
) -> None:
    """Register a function"""

    func = get_proper_type(func)
    if not isinstance(func, CallableType):
        return
    metadata = get_singledispatch_info(singledispatch_obj)
    if metadata is None:
        # if we never added the fallback to the type variables, we already reported an error, so
        # just don't do anything here
        return
    dispatch_type = get_dispatch_type(func, register_arg)
    if dispatch_type is None:
        # TODO: report an error here that singledispatch requires at least one argument
        # (might want to do the error reporting in get_dispatch_type)
        return
    fallback = metadata.fallback

    fallback_dispatch_type = fallback.arg_types[0]
    if not is_subtype(dispatch_type, fallback_dispatch_type):
        fail(
            ctx,
            "Dispatch type {} must be subtype of fallback function first argument {}".format(
                format_type(dispatch_type, options), format_type(fallback_dispatch_type, options)
            ),
            func.definition,
        )
        return
    return

def get_dispatch_type(func: CallableType, register_arg: Type | None) -> Type | None:
    if register_arg is not None:
        return register_arg
    if func.arg_types:
        return func.arg_types[0]
    return None

def call_singledispatch_function_after_register_argument(ctx: MethodContext) -> Type:
    """Called on the function after passing a type to register"""
    register_callable = ctx.type
    if isinstance(register_callable, Instance):
        type_args = RegisterCallableInfo(*register_callable.args)  # type: ignore[arg-type]
        func = get_first_arg(ctx.arg_types)
        if func is not None:
            register_function(
                ctx, type_args.singledispatch_obj, func, ctx.api.options, type_args.register_type
            )
            # see call to register_function in the callback for register
            return func
    return ctx.default_return_type

def call_singledispatch_function_callback(ctx: MethodSigContext) -> FunctionLike:
    """Called for functools._SingleDispatchCallable.__call__"""
    if not isinstance(ctx.type, Instance):
        return ctx.default_signature
    metadata = get_singledispatch_info(ctx.type)
    if metadata is None:
        return ctx.default_signature
    return metadata.fallback


from __future__ import annotations

"""Utilities to find the site and prefix information of a Python executable.

This file MUST remain compatible with all Python 3.8+ versions. Since we cannot make any
assumptions about the Python being executed, this module should not use *any* dependencies outside
of the standard library found in Python 3.8. This file is run each mypy run, so it should be kept
as fast as possible.
"""
import sys

if __name__ == "__main__":
    # HACK: We don't want to pick up mypy.types as the top-level types
    #       module. This could happen if this file is run as a script.
    #       This workaround fixes this for Python versions before 3.11.
    if sys.version_info < (3, 11):
        old_sys_path = sys.path
        sys.path = sys.path[1:]
        import types  # noqa: F401

        sys.path = old_sys_path

import os
import site
import sysconfig

def getsitepackages() -> list[str]:
    res = []
    if hasattr(site, "getsitepackages"):
        res.extend(site.getsitepackages())

        if hasattr(site, "getusersitepackages") and site.ENABLE_USER_SITE:
            res.insert(0, site.getusersitepackages())
    else:
        res = [sysconfig.get_paths()["purelib"]]
    return res

def getsyspath() -> list[str]:
    # Do not include things from the standard library
    # because those should come from typeshed.
    stdlib_zip = os.path.join(
        sys.base_exec_prefix,
        getattr(sys, "platlibdir", "lib"),
        f"python{sys.version_info.major}{sys.version_info.minor}.zip",
    )
    stdlib = sysconfig.get_path("stdlib")
    stdlib_ext = os.path.join(stdlib, "lib-dynload")
    excludes = {stdlib_zip, stdlib, stdlib_ext}

    # Drop the first entry of sys.path
    # - If pyinfo.py is executed as a script (in a subprocess), this is the directory
    #   containing pyinfo.py
    # - Otherwise, if mypy launched via console script, this is the directory of the script
    # - Otherwise, if mypy launched via python -m mypy, this is the current directory
    # In all these cases, it is desirable to drop the first entry
    # Note that mypy adds the cwd to SearchPaths.python_path, so we still find things on the
    # cwd consistently (the return value here sets SearchPaths.package_path)

    # Python 3.11 adds a "safe_path" flag wherein Python won't automatically prepend
    # anything to sys.path. In this case, the first entry of sys.path is no longer special.
    offset = 0 if sys.version_info >= (3, 11) and sys.flags.safe_path else 1

    abs_sys_path = (os.path.abspath(p) for p in sys.path[offset:])
    return [p for p in abs_sys_path if p not in excludes]

def getsearchdirs() -> tuple[list[str], list[str]]:
    return (getsyspath(), getsitepackages())

if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")  # type: ignore[union-attr]
    if sys.argv[-1] == "getsearchdirs":
        print(repr(getsearchdirs()))
    else:
        print("ERROR: incorrect argument to pyinfo.py.", file=sys.stderr)
        sys.exit(1)


"""Utilities related to determining the reachability of code (in semantic analysis)."""

from __future__ import annotations

from typing import Final, Tuple, TypeVar

from mypy.literals import literal
from mypy.nodes import (
    LITERAL_YES,
    AssertStmt,
    Block,
    CallExpr,
    ComparisonExpr,
    Expression,
    FuncDef,
    IfStmt,
    Import,
    ImportAll,
    ImportFrom,
    IndexExpr,
    IntExpr,
    MatchStmt,
    MemberExpr,
    NameExpr,
    OpExpr,
    SliceExpr,
    StrExpr,
    TupleExpr,
    UnaryExpr,
)
from mypy.options import Options
from mypy.patterns import AsPattern, OrPattern, Pattern
from mypy.traverser import TraverserVisitor

# Inferred truth value of an expression.
ALWAYS_TRUE: Final = 1
MYPY_TRUE: Final = 2  # True in mypy, False at runtime
ALWAYS_FALSE: Final = 3
MYPY_FALSE: Final = 4  # False in mypy, True at runtime
TRUTH_VALUE_UNKNOWN: Final = 5

inverted_truth_mapping: Final = {
    ALWAYS_TRUE: ALWAYS_FALSE,
    ALWAYS_FALSE: ALWAYS_TRUE,
    TRUTH_VALUE_UNKNOWN: TRUTH_VALUE_UNKNOWN,
    MYPY_TRUE: MYPY_FALSE,
    MYPY_FALSE: MYPY_TRUE,
}

reverse_op: Final = {"==": "==", "!=": "!=", "<": ">", ">": "<", "<=": ">=", ">=": "<="}

def infer_reachability_of_if_statement(s: IfStmt, options: Options) -> None:
    for i in range(len(s.expr)):
        result = infer_condition_value(s.expr[i], options)
        if result in (ALWAYS_FALSE, MYPY_FALSE):
            # The condition is considered always false, so we skip the if/elif body.
            mark_block_unreachable(s.body[i])
        elif result in (ALWAYS_TRUE, MYPY_TRUE):
            # This condition is considered always true, so all of the remaining
            # elif/else bodies should not be checked.
            if result == MYPY_TRUE:
                # This condition is false at runtime; this will affect
                # import priorities.
                mark_block_mypy_only(s.body[i])
            for body in s.body[i + 1 :]:
                mark_block_unreachable(body)

            # Make sure else body always exists and is marked as
            # unreachable so the type checker always knows that
            # all control flow paths will flow through the if
            # statement body.
            if not s.else_body:
                s.else_body = Block([])
            mark_block_unreachable(s.else_body)
            break

def infer_reachability_of_match_statement(s: MatchStmt, options: Options) -> None:
    for i, guard in enumerate(s.guards):
        pattern_value = infer_pattern_value(s.patterns[i])

        if guard is not None:
            guard_value = infer_condition_value(guard, options)
        else:
            guard_value = ALWAYS_TRUE

        if pattern_value in (ALWAYS_FALSE, MYPY_FALSE) or guard_value in (
            ALWAYS_FALSE,
            MYPY_FALSE,
        ):
            # The case is considered always false, so we skip the case body.
            mark_block_unreachable(s.bodies[i])
        elif pattern_value in (ALWAYS_FALSE, MYPY_TRUE) and guard_value in (
            ALWAYS_TRUE,
            MYPY_TRUE,
        ):
            for body in s.bodies[i + 1 :]:
                mark_block_unreachable(body)

        if guard_value == MYPY_TRUE:
            # This condition is false at runtime; this will affect
            # import priorities.
            mark_block_mypy_only(s.bodies[i])

def assert_will_always_fail(s: AssertStmt, options: Options) -> bool:
    return infer_condition_value(s.expr, options) in (ALWAYS_FALSE, MYPY_FALSE)

def infer_condition_value(expr: Expression, options: Options) -> int:
    """Infer whether the given condition is always true/false.

    Return ALWAYS_TRUE if always true, ALWAYS_FALSE if always false,
    MYPY_TRUE if true under mypy and false at runtime, MYPY_FALSE if
    false under mypy and true at runtime, else TRUTH_VALUE_UNKNOWN.
    """
    pyversion = options.python_version
    name = ""
    negated = False
    alias = expr
    if isinstance(alias, UnaryExpr):
        if alias.op == "not":
            expr = alias.expr
            negated = True
    result = TRUTH_VALUE_UNKNOWN
    if isinstance(expr, NameExpr):
        name = expr.name
    elif isinstance(expr, MemberExpr):
        name = expr.name
    elif isinstance(expr, OpExpr) and expr.op in ("and", "or"):
        left = infer_condition_value(expr.left, options)
        if (left in (ALWAYS_TRUE, MYPY_TRUE) and expr.op == "and") or (
            left in (ALWAYS_FALSE, MYPY_FALSE) and expr.op == "or"
        ):
            # Either `True and <other>` or `False or <other>`: the result will
            # always be the right-hand-side.
            return infer_condition_value(expr.right, options)
        else:
            # The result will always be the left-hand-side (e.g. ALWAYS_* or
            # TRUTH_VALUE_UNKNOWN).
            return left
    else:
        result = consider_sys_version_info(expr, pyversion)
        if result == TRUTH_VALUE_UNKNOWN:
            result = consider_sys_platform(expr, options.platform)
    if result == TRUTH_VALUE_UNKNOWN:
        if name == "PY2":
            result = ALWAYS_FALSE
        elif name == "PY3":
            result = ALWAYS_TRUE
        elif name == "MYPY" or name == "TYPE_CHECKING":
            result = MYPY_TRUE
        elif name in options.always_true:
            result = ALWAYS_TRUE
        elif name in options.always_false:
            result = ALWAYS_FALSE
    if negated:
        result = inverted_truth_mapping[result]
    return result

def infer_pattern_value(pattern: Pattern) -> int:
    if isinstance(pattern, AsPattern) and pattern.pattern is None:
        return ALWAYS_TRUE
    elif isinstance(pattern, OrPattern) and any(
        infer_pattern_value(p) == ALWAYS_TRUE for p in pattern.patterns
    ):
        return ALWAYS_TRUE
    else:
        return TRUTH_VALUE_UNKNOWN

def consider_sys_version_info(expr: Expression, pyversion: tuple[int, ...]) -> int:
    """Consider whether expr is a comparison involving sys.version_info.

    Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN.
    """
    # Cases supported:
    # - sys.version_info[<int>] <compare_op> <int>
    # - sys.version_info[:<int>] <compare_op> <tuple_of_n_ints>
    # - sys.version_info <compare_op> <tuple_of_1_or_2_ints>
    #   (in this case <compare_op> must be >, >=, <, <=, but cannot be ==, !=)
    if not isinstance(expr, ComparisonExpr):
        return TRUTH_VALUE_UNKNOWN
    # Let's not yet support chained comparisons.
    if len(expr.operators) > 1:
        return TRUTH_VALUE_UNKNOWN
    op = expr.operators[0]
    if op not in ("==", "!=", "<=", ">=", "<", ">"):
        return TRUTH_VALUE_UNKNOWN

    index = contains_sys_version_info(expr.operands[0])
    thing = contains_int_or_tuple_of_ints(expr.operands[1])
    if index is None or thing is None:
        index = contains_sys_version_info(expr.operands[1])
        thing = contains_int_or_tuple_of_ints(expr.operands[0])
        op = reverse_op[op]
    if isinstance(index, int) and isinstance(thing, int):
        # sys.version_info[i] <compare_op> k
        if 0 <= index <= 1:
            return fixed_comparison(pyversion[index], op, thing)
        else:
            return TRUTH_VALUE_UNKNOWN
    elif isinstance(index, tuple) and isinstance(thing, tuple):
        lo, hi = index
        if lo is None:
            lo = 0
        if hi is None:
            hi = 2
        if 0 <= lo < hi <= 2:
            val = pyversion[lo:hi]
            if len(val) == len(thing) or len(val) > len(thing) and op not in ("==", "!="):
                return fixed_comparison(val, op, thing)
    return TRUTH_VALUE_UNKNOWN

def consider_sys_platform(expr: Expression, platform: str) -> int:
    """Consider whether expr is a comparison involving sys.platform.

    Return ALWAYS_TRUE, ALWAYS_FALSE, or TRUTH_VALUE_UNKNOWN.
    """
    # Cases supported:
    # - sys.platform == 'posix'
    # - sys.platform != 'win32'
    # - sys.platform.startswith('win')
    if isinstance(expr, ComparisonExpr):
        # Let's not yet support chained comparisons.
        if len(expr.operators) > 1:
            return TRUTH_VALUE_UNKNOWN
        op = expr.operators[0]
        if op not in ("==", "!="):
            return TRUTH_VALUE_UNKNOWN
        if not is_sys_attr(expr.operands[0], "platform"):
            return TRUTH_VALUE_UNKNOWN
        right = expr.operands[1]
        if not isinstance(right, StrExpr):
            return TRUTH_VALUE_UNKNOWN
        return fixed_comparison(platform, op, right.value)
    elif isinstance(expr, CallExpr):
        if not isinstance(expr.callee, MemberExpr):
            return TRUTH_VALUE_UNKNOWN
        if len(expr.args) != 1 or not isinstance(expr.args[0], StrExpr):
            return TRUTH_VALUE_UNKNOWN
        if not is_sys_attr(expr.callee.expr, "platform"):
            return TRUTH_VALUE_UNKNOWN
        if expr.callee.name != "startswith":
            return TRUTH_VALUE_UNKNOWN
        if platform.startswith(expr.args[0].value):
            return ALWAYS_TRUE
        else:
            return ALWAYS_FALSE
    else:
        return TRUTH_VALUE_UNKNOWN

Targ = TypeVar("Targ", int, str, Tuple[int, ...])

def fixed_comparison(left: Targ, op: str, right: Targ) -> int:
    rmap = {False: ALWAYS_FALSE, True: ALWAYS_TRUE}
    if op == "==":
        return rmap[left == right]
    if op == "!=":
        return rmap[left != right]
    if op == "<=":
        return rmap[left <= right]
    if op == ">=":
        return rmap[left >= right]
    if op == "<":
        return rmap[left < right]
    if op == ">":
        return rmap[left > right]
    return TRUTH_VALUE_UNKNOWN

def contains_int_or_tuple_of_ints(expr: Expression) -> None | int | tuple[int, ...]:
    if isinstance(expr, IntExpr):
        return expr.value
    if isinstance(expr, TupleExpr):
        if literal(expr) == LITERAL_YES:
            thing = []
            for x in expr.items:
                if not isinstance(x, IntExpr):
                    return None
                thing.append(x.value)
            return tuple(thing)
    return None

def contains_sys_version_info(expr: Expression) -> None | int | tuple[int | None, int | None]:
    if is_sys_attr(expr, "version_info"):
        return (None, None)  # Same as sys.version_info[:]
    if isinstance(expr, IndexExpr) and is_sys_attr(expr.base, "version_info"):
        index = expr.index
        if isinstance(index, IntExpr):
            return index.value
        if isinstance(index, SliceExpr):
            if index.stride is not None:
                if not isinstance(index.stride, IntExpr) or index.stride.value != 1:
                    return None
            begin = end = None
            if index.begin_index is not None:
                if not isinstance(index.begin_index, IntExpr):
                    return None
                begin = index.begin_index.value
            if index.end_index is not None:
                if not isinstance(index.end_index, IntExpr):
                    return None
                end = index.end_index.value
            return (begin, end)
    return None

def is_sys_attr(expr: Expression, name: str) -> bool:
    # TODO: This currently doesn't work with code like this:
    # - import sys as _sys
    # - from sys import version_info
    if isinstance(expr, MemberExpr) and expr.name == name:
        if isinstance(expr.expr, NameExpr) and expr.expr.name == "sys":
            # TODO: Guard against a local named sys, etc.
            # (Though later passes will still do most checking.)
            return True
    return False

def mark_block_unreachable(block: Block) -> None:
    block.is_unreachable = True
    block.accept(MarkImportsUnreachableVisitor())

class MarkImportsUnreachableVisitor(TraverserVisitor):
    """Visitor that flags all imports nested within a node as unreachable."""

    def visit_import(self, node: Import) -> None:
        node.is_unreachable = True

    def visit_import_from(self, node: ImportFrom) -> None:
        node.is_unreachable = True

    def visit_import_all(self, node: ImportAll) -> None:
        node.is_unreachable = True

def mark_block_mypy_only(block: Block) -> None:
    block.accept(MarkImportsMypyOnlyVisitor())

class MarkImportsMypyOnlyVisitor(TraverserVisitor):
    """Visitor that sets is_mypy_only (which affects priority)."""

    def visit_import(self, node: Import) -> None:
        node.is_mypy_only = True

    def visit_import_from(self, node: ImportFrom) -> None:
        node.is_mypy_only = True

    def visit_import_all(self, node: ImportAll) -> None:
        node.is_mypy_only = True

    def visit_func_def(self, node: FuncDef) -> None:
        node.is_mypy_only = True


"""Find line-level reference information from a mypy AST (undocumented feature)"""

from __future__ import annotations

from mypy.nodes import (
    LDEF,
    Expression,
    FuncDef,
    MemberExpr,
    MypyFile,
    NameExpr,
    RefExpr,
    SymbolNode,
    TypeInfo,
)
from mypy.traverser import TraverserVisitor
from mypy.typeops import tuple_fallback
from mypy.types import (
    FunctionLike,
    Instance,
    TupleType,
    Type,
    TypeType,
    TypeVarLikeType,
    get_proper_type,
)

class RefInfoVisitor(TraverserVisitor):
    def __init__(self, type_map: dict[Expression, Type]) -> None:
        super().__init__()
        self.type_map = type_map
        self.data: list[dict[str, object]] = []

    def visit_name_expr(self, expr: NameExpr) -> None:
        super().visit_name_expr(expr)
        self.record_ref_expr(expr)

    def visit_member_expr(self, expr: MemberExpr) -> None:
        super().visit_member_expr(expr)
        self.record_ref_expr(expr)

    def visit_func_def(self, func: FuncDef) -> None:
        if func.expanded:
            for item in func.expanded:
                if isinstance(item, FuncDef):
                    super().visit_func_def(item)
        else:
            super().visit_func_def(func)

    def record_ref_expr(self, expr: RefExpr) -> None:
        fullname = None
        if expr.kind != LDEF and "." in expr.fullname:
            fullname = expr.fullname
        elif isinstance(expr, MemberExpr):
            typ = self.type_map.get(expr.expr)
            sym = None
            if isinstance(expr.expr, RefExpr):
                sym = expr.expr.node
            if typ:
                tfn = type_fullname(typ, sym)
                if tfn:
                    fullname = f"{tfn}.{expr.name}"
            if not fullname:
                fullname = f"*.{expr.name}"
        if fullname is not None:
            self.data.append({"line": expr.line, "column": expr.column, "target": fullname})

def type_fullname(typ: Type, node: SymbolNode | None = None) -> str | None:
    typ = get_proper_type(typ)
    if isinstance(typ, Instance):
        return typ.type.fullname
    elif isinstance(typ, TypeType):
        return type_fullname(typ.item)
    elif isinstance(typ, FunctionLike) and typ.is_type_obj():
        if isinstance(node, TypeInfo):
            return node.fullname
        return type_fullname(typ.fallback)
    elif isinstance(typ, TupleType):
        return type_fullname(tuple_fallback(typ))
    elif isinstance(typ, TypeVarLikeType):
        return type_fullname(typ.upper_bound)
    return None

def get_undocumented_ref_info_json(
    tree: MypyFile, type_map: dict[Expression, Type]
) -> list[dict[str, object]]:
    visitor = RefInfoVisitor(type_map)
    tree.accept(visitor)
    return visitor.data


from __future__ import annotations

from contextlib import contextmanager
from typing import Final, Iterator

from mypy.nodes import (
    AssignmentStmt,
    Block,
    BreakStmt,
    ClassDef,
    ContinueStmt,
    ForStmt,
    FuncDef,
    Import,
    ImportAll,
    ImportFrom,
    IndexExpr,
    ListExpr,
    Lvalue,
    MatchStmt,
    MemberExpr,
    MypyFile,
    NameExpr,
    StarExpr,
    TryStmt,
    TupleExpr,
    WhileStmt,
    WithStmt,
)
from mypy.patterns import AsPattern
from mypy.traverser import TraverserVisitor

# Scope kinds
FILE: Final = 0
FUNCTION: Final = 1
CLASS: Final = 2

class VariableRenameVisitor(TraverserVisitor):
    """Rename variables to allow redefinition of variables.

    For example, consider this code:

      x = 0
      f(x)

      x = "a"
      g(x)

    It will be transformed like this:

      x' = 0
      f(x')

      x = "a"
      g(x)

    There will be two independent variables (x' and x) that will have separate
    inferred types. The publicly exposed variant will get the non-suffixed name.
    This is the last definition at module top level and the first definition
    (argument) within a function.

    Renaming only happens for assignments within the same block. Renaming is
    performed before semantic analysis, immediately after parsing.

    The implementation performs a rudimentary static analysis. The analysis is
    overly conservative to keep things simple.
    """

    def __init__(self) -> None:
        # Counter for labeling new blocks
        self.block_id = 0
        # Number of surrounding try statements that disallow variable redefinition
        self.disallow_redef_depth = 0
        # Number of surrounding loop statements
        self.loop_depth = 0
        # Map block id to loop depth.
        self.block_loop_depth: dict[int, int] = {}
        # Stack of block ids being processed.
        self.blocks: list[int] = []
        # List of scopes; each scope maps short (unqualified) name to block id.
        self.var_blocks: list[dict[str, int]] = []

        # References to variables that we may need to rename. List of
        # scopes; each scope is a mapping from name to list of collections
        # of names that refer to the same logical variable.
        self.refs: list[dict[str, list[list[NameExpr]]]] = []
        # Number of reads of the most recent definition of a variable (per scope)
        self.num_reads: list[dict[str, int]] = []
        # Kinds of nested scopes (FILE, FUNCTION or CLASS)
        self.scope_kinds: list[int] = []

    def visit_mypy_file(self, file_node: MypyFile) -> None:
        """Rename variables within a file.

        This is the main entry point to this class.
        """
        self.clear()
        with self.enter_scope(FILE), self.enter_block():
            for d in file_node.defs:
                d.accept(self)

    def visit_func_def(self, fdef: FuncDef) -> None:
        # Conservatively do not allow variable defined before a function to
        # be redefined later, since function could refer to either definition.
        self.reject_redefinition_of_vars_in_scope()

        with self.enter_scope(FUNCTION), self.enter_block():
            for arg in fdef.arguments:
                name = arg.variable.name
                # 'self' can't be redefined since it's special as it allows definition of
                # attributes. 'cls' can't be used to define attributes so we can ignore it.
                can_be_redefined = name != "self"  # TODO: Proper check
                self.record_assignment(arg.variable.name, can_be_redefined)
                self.handle_arg(name)

            for stmt in fdef.body.body:
                stmt.accept(self)

    def visit_class_def(self, cdef: ClassDef) -> None:
        self.reject_redefinition_of_vars_in_scope()
        with self.enter_scope(CLASS):
            super().visit_class_def(cdef)

    def visit_block(self, block: Block) -> None:
        with self.enter_block():
            super().visit_block(block)

    def visit_while_stmt(self, stmt: WhileStmt) -> None:
        with self.enter_loop():
            super().visit_while_stmt(stmt)

    def visit_for_stmt(self, stmt: ForStmt) -> None:
        stmt.expr.accept(self)
        self.analyze_lvalue(stmt.index, True)
        # Also analyze as non-lvalue so that every for loop index variable is assumed to be read.
        stmt.index.accept(self)
        with self.enter_loop():
            stmt.body.accept(self)
        if stmt.else_body:
            stmt.else_body.accept(self)

    def visit_break_stmt(self, stmt: BreakStmt) -> None:
        self.reject_redefinition_of_vars_in_loop()

    def visit_continue_stmt(self, stmt: ContinueStmt) -> None:
        self.reject_redefinition_of_vars_in_loop()

    def visit_try_stmt(self, stmt: TryStmt) -> None:
        # Variables defined by a try statement get special treatment in the
        # type checker which allows them to be always redefined, so no need to
        # do renaming here.
        with self.enter_try():
            super().visit_try_stmt(stmt)

    def visit_with_stmt(self, stmt: WithStmt) -> None:
        for expr in stmt.expr:
            expr.accept(self)
        for target in stmt.target:
            if target is not None:
                self.analyze_lvalue(target)
        # We allow redefinitions in the body of a with statement for
        # convenience.  This is unsafe since with statements can affect control
        # flow by catching exceptions, but this is rare except for
        # assertRaises() and other similar functions, where the exception is
        # raised by the last statement in the body, which usually isn't a
        # problem.
        stmt.body.accept(self)

    def visit_import(self, imp: Import) -> None:
        for id, as_id in imp.ids:
            self.record_assignment(as_id or id, False)

    def visit_import_from(self, imp: ImportFrom) -> None:
        for id, as_id in imp.names:
            self.record_assignment(as_id or id, False)

    def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
        s.rvalue.accept(self)
        for lvalue in s.lvalues:
            self.analyze_lvalue(lvalue)

    def visit_match_stmt(self, s: MatchStmt) -> None:
        s.subject.accept(self)
        for i in range(len(s.patterns)):
            with self.enter_block():
                s.patterns[i].accept(self)
                guard = s.guards[i]
                if guard is not None:
                    guard.accept(self)
                # We already entered a block, so visit this block's statements directly
                for stmt in s.bodies[i].body:
                    stmt.accept(self)

    def visit_capture_pattern(self, p: AsPattern) -> None:
        if p.name is not None:
            self.analyze_lvalue(p.name)

    def analyze_lvalue(self, lvalue: Lvalue, is_nested: bool = False) -> None:
        """Process assignment; in particular, keep track of (re)defined names.

        Args:
            is_nested: True for non-outermost Lvalue in a multiple assignment such as
                "x, y = ..."
        """
        if isinstance(lvalue, NameExpr):
            name = lvalue.name
            is_new = self.record_assignment(name, True)
            if is_new:
                self.handle_def(lvalue)
            else:
                self.handle_refine(lvalue)
            if is_nested:
                # This allows these to be redefined freely even if never read. Multiple
                # assignment like "x, _ _ = y" defines dummy variables that are never read.
                self.handle_ref(lvalue)
        elif isinstance(lvalue, (ListExpr, TupleExpr)):
            for item in lvalue.items:
                self.analyze_lvalue(item, is_nested=True)
        elif isinstance(lvalue, MemberExpr):
            lvalue.expr.accept(self)
        elif isinstance(lvalue, IndexExpr):
            lvalue.base.accept(self)
            lvalue.index.accept(self)
        elif isinstance(lvalue, StarExpr):
            # Propagate is_nested since in a typical use case like "x, *rest = ..." 'rest' may
            # be freely reused.
            self.analyze_lvalue(lvalue.expr, is_nested=is_nested)

    def visit_name_expr(self, expr: NameExpr) -> None:
        self.handle_ref(expr)

    # Helpers for renaming references

    def handle_arg(self, name: str) -> None:
        """Store function argument."""
        self.refs[-1][name] = [[]]
        self.num_reads[-1][name] = 0

    def handle_def(self, expr: NameExpr) -> None:
        """Store new name definition."""
        name = expr.name
        names = self.refs[-1].setdefault(name, [])
        names.append([expr])
        self.num_reads[-1][name] = 0

    def handle_refine(self, expr: NameExpr) -> None:
        """Store assignment to an existing name (that replaces previous value, if any)."""
        name = expr.name
        if name in self.refs[-1]:
            names = self.refs[-1][name]
            if not names:
                names.append([])
            names[-1].append(expr)

    def handle_ref(self, expr: NameExpr) -> None:
        """Store reference to defined name."""
        name = expr.name
        if name in self.refs[-1]:
            names = self.refs[-1][name]
            if not names:
                names.append([])
            names[-1].append(expr)
        num_reads = self.num_reads[-1]
        num_reads[name] = num_reads.get(name, 0) + 1

    def flush_refs(self) -> None:
        """Rename all references within the current scope.

        This will be called at the end of a scope.
        """
        is_func = self.scope_kinds[-1] == FUNCTION
        for refs in self.refs[-1].values():
            if len(refs) == 1:
                # Only one definition -- no renaming needed.
                continue
            if is_func:
                # In a function, don't rename the first definition, as it
                # may be an argument that must preserve the name.
                to_rename = refs[1:]
            else:
                # At module top level, don't rename the final definition,
                # as it will be publicly visible outside the module.
                to_rename = refs[:-1]
            for i, item in enumerate(to_rename):
                rename_refs(item, i)
        self.refs.pop()

    # Helpers for determining which assignments define new variables

    def clear(self) -> None:
        self.blocks = []
        self.var_blocks = []

    @contextmanager
    def enter_block(self) -> Iterator[None]:
        self.block_id += 1
        self.blocks.append(self.block_id)
        self.block_loop_depth[self.block_id] = self.loop_depth
        try:
            yield
        finally:
            self.blocks.pop()

    @contextmanager
    def enter_try(self) -> Iterator[None]:
        self.disallow_redef_depth += 1
        try:
            yield
        finally:
            self.disallow_redef_depth -= 1

    @contextmanager
    def enter_loop(self) -> Iterator[None]:
        self.loop_depth += 1
        try:
            yield
        finally:
            self.loop_depth -= 1

    def current_block(self) -> int:
        return self.blocks[-1]

    @contextmanager
    def enter_scope(self, kind: int) -> Iterator[None]:
        self.var_blocks.append({})
        self.refs.append({})
        self.num_reads.append({})
        self.scope_kinds.append(kind)
        try:
            yield
        finally:
            self.flush_refs()
            self.var_blocks.pop()
            self.num_reads.pop()
            self.scope_kinds.pop()

    def is_nested(self) -> int:
        return len(self.var_blocks) > 1

    def reject_redefinition_of_vars_in_scope(self) -> None:
        """Make it impossible to redefine defined variables in the current scope.

        This is used if we encounter a function definition that
        can make it ambiguous which definition is live. Example:

          x = 0

          def f() -> int:
              return x

          x = ''  # Error -- cannot redefine x across function definition
        """
        var_blocks = self.var_blocks[-1]
        for key in var_blocks:
            var_blocks[key] = -1

    def reject_redefinition_of_vars_in_loop(self) -> None:
        """Reject redefinition of variables in the innermost loop.

        If there is an early exit from a loop, there may be ambiguity about which
        value may escape the loop. Example where this matters:

          while f():
              x = 0
              if g():
                  break
              x = ''  # Error -- not a redefinition
          reveal_type(x)  # int

        This method ensures that the second assignment to 'x' doesn't introduce a new
        variable.
        """
        var_blocks = self.var_blocks[-1]
        for key, block in var_blocks.items():
            if self.block_loop_depth.get(block) == self.loop_depth:
                var_blocks[key] = -1

    def record_assignment(self, name: str, can_be_redefined: bool) -> bool:
        """Record assignment to given name and return True if it defines a new variable.

        Args:
            can_be_redefined: If True, allows assignment in the same block to redefine
                this name (if this is a new definition)
        """
        if self.num_reads[-1].get(name, -1) == 0:
            # Only set, not read, so no reason to redefine
            return False
        if self.disallow_redef_depth > 0:
            # Can't redefine within try/with a block.
            can_be_redefined = False
        block = self.current_block()
        var_blocks = self.var_blocks[-1]
        if name not in var_blocks:
            # New definition in this scope.
            if can_be_redefined:
                # Store the block where this was defined to allow redefinition in
                # the same block only.
                var_blocks[name] = block
            else:
                # This doesn't support arbitrary redefinition.
                var_blocks[name] = -1
            return True
        elif var_blocks[name] == block:
            # Redefinition -- defines a new variable with the same name.
            return True
        else:
            # Assigns to an existing variable.
            return False

class LimitedVariableRenameVisitor(TraverserVisitor):
    """Perform some limited variable renaming in with statements.

    This allows reusing a variable in multiple with statements with
    different types. For example, the two instances of 'x' can have
    incompatible types:

       with C() as x:
           f(x)
       with D() as x:
           g(x)

    The above code gets renamed conceptually into this (not valid Python!):

       with C() as x':
           f(x')
       with D() as x:
           g(x)

    If there's a reference to a variable defined in 'with' outside the
    statement, or if there's any trickiness around variable visibility
    (e.g. function definitions), we give up and won't perform renaming.

    The main use case is to allow binding both readable and writable
    binary files into the same variable. These have different types:

        with open(fnam, 'rb') as f: ...
        with open(fnam, 'wb') as f: ...
    """

    def __init__(self) -> None:
        # Short names of variables bound in with statements using "as"
        # in a surrounding scope
        self.bound_vars: list[str] = []
        # Stack of names that can't be safely renamed, per scope ('*' means that
        # no names can be renamed)
        self.skipped: list[set[str]] = []
        # References to variables that we may need to rename. Stack of
        # scopes; each scope is a mapping from name to list of collections
        # of names that refer to the same logical variable.
        self.refs: list[dict[str, list[list[NameExpr]]]] = []

    def visit_mypy_file(self, file_node: MypyFile) -> None:
        """Rename variables within a file.

        This is the main entry point to this class.
        """
        with self.enter_scope():
            for d in file_node.defs:
                d.accept(self)

    def visit_func_def(self, fdef: FuncDef) -> None:
        self.reject_redefinition_of_vars_in_scope()
        with self.enter_scope():
            for arg in fdef.arguments:
                self.record_skipped(arg.variable.name)
            super().visit_func_def(fdef)

    def visit_class_def(self, cdef: ClassDef) -> None:
        self.reject_redefinition_of_vars_in_scope()
        with self.enter_scope():
            super().visit_class_def(cdef)

    def visit_with_stmt(self, stmt: WithStmt) -> None:
        for expr in stmt.expr:
            expr.accept(self)
        old_len = len(self.bound_vars)
        for target in stmt.target:
            if target is not None:
                self.analyze_lvalue(target)
        for target in stmt.target:
            if target:
                target.accept(self)
        stmt.body.accept(self)

        while len(self.bound_vars) > old_len:
            self.bound_vars.pop()

    def analyze_lvalue(self, lvalue: Lvalue) -> None:
        if isinstance(lvalue, NameExpr):
            name = lvalue.name
            if name in self.bound_vars:
                # Name bound in a surrounding with statement, so it can be renamed
                self.visit_name_expr(lvalue)
            else:
                var_info = self.refs[-1]
                if name not in var_info:
                    var_info[name] = []
                var_info[name].append([])
                self.bound_vars.append(name)
        elif isinstance(lvalue, (ListExpr, TupleExpr)):
            for item in lvalue.items:
                self.analyze_lvalue(item)
        elif isinstance(lvalue, MemberExpr):
            lvalue.expr.accept(self)
        elif isinstance(lvalue, IndexExpr):
            lvalue.base.accept(self)
            lvalue.index.accept(self)
        elif isinstance(lvalue, StarExpr):
            self.analyze_lvalue(lvalue.expr)

    def visit_import(self, imp: Import) -> None:
        # We don't support renaming imports
        for id, as_id in imp.ids:
            self.record_skipped(as_id or id)

    def visit_import_from(self, imp: ImportFrom) -> None:
        # We don't support renaming imports
        for id, as_id in imp.names:
            self.record_skipped(as_id or id)

    def visit_import_all(self, imp: ImportAll) -> None:
        # Give up, since we don't know all imported names yet
        self.reject_redefinition_of_vars_in_scope()

    def visit_name_expr(self, expr: NameExpr) -> None:
        name = expr.name
        if name in self.bound_vars:
            # Record reference so that it can be renamed later
            for scope in reversed(self.refs):
                if name in scope:
                    scope[name][-1].append(expr)
        else:
            self.record_skipped(name)

    @contextmanager
    def enter_scope(self) -> Iterator[None]:
        self.skipped.append(set())
        self.refs.append({})
        yield None
        self.flush_refs()

    def reject_redefinition_of_vars_in_scope(self) -> None:
        self.record_skipped("*")

    def record_skipped(self, name: str) -> None:
        self.skipped[-1].add(name)

    def flush_refs(self) -> None:
        ref_dict = self.refs.pop()
        skipped = self.skipped.pop()
        if "*" not in skipped:
            for name, refs in ref_dict.items():
                if len(refs) <= 1 or name in skipped:
                    continue
                # At module top level we must not rename the final definition,
                # as it may be publicly visible
                to_rename = refs[:-1]
                for i, item in enumerate(to_rename):
                    rename_refs(item, i)

def rename_refs(names: list[NameExpr], index: int) -> None:
    name = names[0].name
    new_name = name + "'" * (index + 1)
    for expr in names:
        expr.name = new_name


"""Classes for producing HTML reports about imprecision."""

from __future__ import annotations

import collections
import itertools
import json
import os
import shutil
import sys
import time
import tokenize
from abc import ABCMeta, abstractmethod
from operator import attrgetter
from typing import Any, Callable, Dict, Final, Iterator, Tuple
from typing_extensions import TypeAlias as _TypeAlias
from urllib.request import pathname2url

from mypy import stats
from mypy.defaults import REPORTER_NAMES
from mypy.nodes import Expression, FuncDef, MypyFile
from mypy.options import Options
from mypy.traverser import TraverserVisitor
from mypy.types import Type, TypeOfAny
from mypy.version import __version__

try:
    from lxml import etree  # type: ignore[import-untyped]

    LXML_INSTALLED = True
except ImportError:
    LXML_INSTALLED = False

type_of_any_name_map: Final[collections.OrderedDict[int, str]] = collections.OrderedDict(
    [
        (TypeOfAny.unannotated, "Unannotated"),
        (TypeOfAny.explicit, "Explicit"),
        (TypeOfAny.from_unimported_type, "Unimported"),
        (TypeOfAny.from_omitted_generics, "Omitted Generics"),
        (TypeOfAny.from_error, "Error"),
        (TypeOfAny.special_form, "Special Form"),
        (TypeOfAny.implementation_artifact, "Implementation Artifact"),
    ]
)

ReporterClasses: _TypeAlias = Dict[
    str, Tuple[Callable[["Reports", str], "AbstractReporter"], bool]
]

reporter_classes: Final[ReporterClasses] = {}

class Reports:
    def __init__(self, data_dir: str, report_dirs: dict[str, str]) -> None:
        self.data_dir = data_dir
        self.reporters: list[AbstractReporter] = []
        self.named_reporters: dict[str, AbstractReporter] = {}

        for report_type, report_dir in sorted(report_dirs.items()):
            self.add_report(report_type, report_dir)

    def add_report(self, report_type: str, report_dir: str) -> AbstractReporter:
        try:
            return self.named_reporters[report_type]
        except KeyError:
            pass
        reporter_cls, needs_lxml = reporter_classes[report_type]
        if needs_lxml and not LXML_INSTALLED:
            print(
                (
                    "You must install the lxml package before you can run mypy"
                    " with `--{}-report`.\n"
                    "You can do this with `python3 -m pip install lxml`."
                ).format(report_type),
                file=sys.stderr,
            )
            raise ImportError
        reporter = reporter_cls(self, report_dir)
        self.reporters.append(reporter)
        self.named_reporters[report_type] = reporter
        return reporter

    def file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        for reporter in self.reporters:
            reporter.on_file(tree, modules, type_map, options)

    def finish(self) -> None:
        for reporter in self.reporters:
            reporter.on_finish()

class AbstractReporter(metaclass=ABCMeta):
    def __init__(self, reports: Reports, output_dir: str) -> None:
        self.output_dir = output_dir
        if output_dir != "<memory>":
            os.makedirs(output_dir, exist_ok=True)

    @abstractmethod
    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        pass

    @abstractmethod
    def on_finish(self) -> None:
        pass

def register_reporter(
    report_name: str,
    reporter: Callable[[Reports, str], AbstractReporter],
    needs_lxml: bool = False,
) -> None:
    reporter_classes[report_name] = (reporter, needs_lxml)

def alias_reporter(source_reporter: str, target_reporter: str) -> None:
    reporter_classes[target_reporter] = reporter_classes[source_reporter]

def should_skip_path(path: str) -> bool:
    if stats.is_special_module(path):
        return True
    if path.startswith(".."):
        return True
    if "stubs" in path.split("/") or "stubs" in path.split(os.sep):
        return True
    return False

def iterate_python_lines(path: str) -> Iterator[tuple[int, str]]:
    """Return an iterator over (line number, line text) from a Python file."""
    try:
        with tokenize.open(path) as input_file:
            yield from enumerate(input_file, 1)
    except IsADirectoryError:
        # can happen with namespace packages
        pass

class FuncCounterVisitor(TraverserVisitor):
    def __init__(self) -> None:
        super().__init__()
        self.counts = [0, 0]

    def visit_func_def(self, defn: FuncDef) -> None:
        self.counts[defn.type is not None] += 1

class LineCountReporter(AbstractReporter):
    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)
        self.counts: dict[str, tuple[int, int, int, int]] = {}

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        # Count physical lines.  This assumes the file's encoding is a
        # superset of ASCII (or at least uses \n in its line endings).
        try:
            with open(tree.path, "rb") as f:
                physical_lines = len(f.readlines())
        except IsADirectoryError:
            # can happen with namespace packages
            physical_lines = 0

        func_counter = FuncCounterVisitor()
        tree.accept(func_counter)
        unannotated_funcs, annotated_funcs = func_counter.counts
        total_funcs = annotated_funcs + unannotated_funcs

        # Don't count lines or functions as annotated if they have their errors ignored.
        if options.ignore_errors:
            annotated_funcs = 0

        imputed_annotated_lines = (
            physical_lines * annotated_funcs // total_funcs if total_funcs else physical_lines
        )

        self.counts[tree._fullname] = (
            imputed_annotated_lines,
            physical_lines,
            annotated_funcs,
            total_funcs,
        )

    def on_finish(self) -> None:
        counts: list[tuple[tuple[int, int, int, int], str]] = sorted(
            ((c, p) for p, c in self.counts.items()), reverse=True
        )
        total_counts = tuple(sum(c[i] for c, p in counts) for i in range(4))
        with open(os.path.join(self.output_dir, "linecount.txt"), "w") as f:
            f.write("{:7} {:7} {:6} {:6} total\n".format(*total_counts))
            for c, p in counts:
                f.write(f"{c[0]:7} {c[1]:7} {c[2]:6} {c[3]:6} {p}\n")

register_reporter("linecount", LineCountReporter)

class AnyExpressionsReporter(AbstractReporter):
    """Report frequencies of different kinds of Any types."""

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)
        self.counts: dict[str, tuple[int, int]] = {}
        self.any_types_counter: dict[str, collections.Counter[int]] = {}

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        visitor = stats.StatisticsVisitor(
            inferred=True,
            filename=tree.fullname,
            modules=modules,
            typemap=type_map,
            all_nodes=True,
            visit_untyped_defs=False,
        )
        tree.accept(visitor)
        self.any_types_counter[tree.fullname] = visitor.type_of_any_counter
        num_unanalyzed_lines = list(visitor.line_map.values()).count(stats.TYPE_UNANALYZED)
        # count each line of dead code as one expression of type "Any"
        num_any = visitor.num_any_exprs + num_unanalyzed_lines
        num_total = visitor.num_imprecise_exprs + visitor.num_precise_exprs + num_any
        if num_total > 0:
            self.counts[tree.fullname] = (num_any, num_total)

    def on_finish(self) -> None:
        self._report_any_exprs()
        self._report_types_of_anys()

    def _write_out_report(
        self, filename: str, header: list[str], rows: list[list[str]], footer: list[str]
    ) -> None:
        row_len = len(header)
        assert all(len(row) == row_len for row in rows + [header, footer])
        min_column_distance = 3  # minimum distance between numbers in two columns
        widths = [-1] * row_len
        for row in rows + [header, footer]:
            for i, value in enumerate(row):
                widths[i] = max(widths[i], len(value))
        for i, w in enumerate(widths):
            # Do not add min_column_distance to the first column.
            if i > 0:
                widths[i] = w + min_column_distance
        with open(os.path.join(self.output_dir, filename), "w") as f:
            header_str = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(header, widths)))
            separator = "-" * len(header_str)
            f.write(header_str + "\n")
            f.write(separator + "\n")
            for row_values in rows:
                r = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(row_values, widths)))
                f.write(r + "\n")
            f.write(separator + "\n")
            footer_str = ("{:>{}}" * len(widths)).format(*itertools.chain(*zip(footer, widths)))
            f.write(footer_str + "\n")

    def _report_any_exprs(self) -> None:
        total_any = sum(num_any for num_any, _ in self.counts.values())
        total_expr = sum(total for _, total in self.counts.values())
        total_coverage = 100.0
        if total_expr > 0:
            total_coverage = (float(total_expr - total_any) / float(total_expr)) * 100

        column_names = ["Name", "Anys", "Exprs", "Coverage"]
        rows: list[list[str]] = []
        for filename in sorted(self.counts):
            (num_any, num_total) = self.counts[filename]
            coverage = (float(num_total - num_any) / float(num_total)) * 100
            coverage_str = f"{coverage:.2f}%"
            rows.append([filename, str(num_any), str(num_total), coverage_str])
        rows.sort(key=lambda x: x[0])
        total_row = ["Total", str(total_any), str(total_expr), f"{total_coverage:.2f}%"]
        self._write_out_report("any-exprs.txt", column_names, rows, total_row)

    def _report_types_of_anys(self) -> None:
        total_counter: collections.Counter[int] = collections.Counter()
        for counter in self.any_types_counter.values():
            for any_type, value in counter.items():
                total_counter[any_type] += value
        file_column_name = "Name"
        total_row_name = "Total"
        column_names = [file_column_name] + list(type_of_any_name_map.values())
        rows: list[list[str]] = []
        for filename, counter in self.any_types_counter.items():
            rows.append([filename] + [str(counter[typ]) for typ in type_of_any_name_map])
        rows.sort(key=lambda x: x[0])
        total_row = [total_row_name] + [str(total_counter[typ]) for typ in type_of_any_name_map]
        self._write_out_report("types-of-anys.txt", column_names, rows, total_row)

register_reporter("any-exprs", AnyExpressionsReporter)

class LineCoverageVisitor(TraverserVisitor):
    def __init__(self, source: list[str]) -> None:
        self.source = source

        # For each line of source, we maintain a pair of
        #  * the indentation level of the surrounding function
        #    (-1 if not inside a function), and
        #  * whether the surrounding function is typed.
        # Initially, everything is covered at indentation level -1.
        self.lines_covered = [(-1, True) for l in source]

    # The Python AST has position information for the starts of
    # elements, but not for their ends. Fortunately the
    # indentation-based syntax makes it pretty easy to find where a
    # block ends without doing any real parsing.

    # TODO: Handle line continuations (explicit and implicit) and
    # multi-line string literals. (But at least line continuations
    # are normally more indented than their surrounding block anyways,
    # by PEP 8.)

    def indentation_level(self, line_number: int) -> int | None:
        """Return the indentation of a line of the source (specified by
        zero-indexed line number). Returns None for blank lines or comments."""
        line = self.source[line_number]
        indent = 0
        for char in list(line):
            if char == " ":
                indent += 1
            elif char == "\t":
                indent = 8 * ((indent + 8) // 8)
            elif char == "#":
                # Line is a comment; ignore it
                return None
            elif char == "\n":
                # Line is entirely whitespace; ignore it
                return None
            # TODO line continuation (\)
            else:
                # Found a non-whitespace character
                return indent
        # Line is entirely whitespace, and at end of file
        # with no trailing newline; ignore it
        return None

    def visit_func_def(self, defn: FuncDef) -> None:
        start_line = defn.line - 1
        start_indent = None
        # When a function is decorated, sometimes the start line will point to
        # whitespace or comments between the decorator and the function, so
        # we have to look for the start.
        while start_line < len(self.source):
            start_indent = self.indentation_level(start_line)
            if start_indent is not None:
                break
            start_line += 1
        # If we can't find the function give up and don't annotate anything.
        # Our line numbers are not reliable enough to be asserting on.
        if start_indent is None:
            return

        cur_line = start_line + 1
        end_line = cur_line
        # After this loop, function body will be lines [start_line, end_line)
        while cur_line < len(self.source):
            cur_indent = self.indentation_level(cur_line)
            if cur_indent is None:
                # Consume the line, but don't mark it as belonging to the function yet.
                cur_line += 1
            elif cur_indent > start_indent:
                # A non-blank line that belongs to the function.
                cur_line += 1
                end_line = cur_line
            else:
                # We reached a line outside the function definition.
                break

        is_typed = defn.type is not None
        for line in range(start_line, end_line):
            old_indent, _ = self.lines_covered[line]
            # If there was an old indent level for this line, and the new
            # level isn't increasing the indentation, ignore it.
            # This is to be defensive against funniness in our line numbers,
            # which are not always reliable.
            if old_indent <= start_indent:
                self.lines_covered[line] = (start_indent, is_typed)

        # Visit the body, in case there are nested functions
        super().visit_func_def(defn)

class LineCoverageReporter(AbstractReporter):
    """Exact line coverage reporter.

    This reporter writes a JSON dictionary with one field 'lines' to
    the file 'coverage.json' in the specified report directory. The
    value of that field is a dictionary which associates to each
    source file's absolute pathname the list of line numbers that
    belong to typed functions in that file.
    """

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)
        self.lines_covered: dict[str, list[int]] = {}

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        with open(tree.path) as f:
            tree_source = f.readlines()

        coverage_visitor = LineCoverageVisitor(tree_source)
        tree.accept(coverage_visitor)

        covered_lines = []
        for line_number, (_, typed) in enumerate(coverage_visitor.lines_covered):
            if typed:
                covered_lines.append(line_number + 1)

        self.lines_covered[os.path.abspath(tree.path)] = covered_lines

    def on_finish(self) -> None:
        with open(os.path.join(self.output_dir, "coverage.json"), "w") as f:
            json.dump({"lines": self.lines_covered}, f)

register_reporter("linecoverage", LineCoverageReporter)

class FileInfo:
    def __init__(self, name: str, module: str) -> None:
        self.name = name
        self.module = module
        self.counts = [0] * len(stats.precision_names)

    def total(self) -> int:
        return sum(self.counts)

    def attrib(self) -> dict[str, str]:
        return {name: str(val) for name, val in sorted(zip(stats.precision_names, self.counts))}

class MemoryXmlReporter(AbstractReporter):
    """Internal reporter that generates XML in memory.

    This is used by all other XML-based reporters to avoid duplication.
    """

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)

        self.xslt_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.xslt")
        self.xslt_txt_path = os.path.join(reports.data_dir, "xml", "mypy-txt.xslt")
        self.css_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.css")
        xsd_path = os.path.join(reports.data_dir, "xml", "mypy.xsd")
        self.schema = etree.XMLSchema(etree.parse(xsd_path))
        self.last_xml: Any | None = None
        self.files: list[FileInfo] = []

    # XML doesn't like control characters, but they are sometimes
    # legal in source code (e.g. comments, string literals).
    # Tabs (#x09) are allowed in XML content.
    control_fixer: Final = str.maketrans("".join(chr(i) for i in range(32) if i != 9), "?" * 31)

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        self.last_xml = None

        try:
            path = os.path.relpath(tree.path)
        except ValueError:
            return

        if should_skip_path(path) or os.path.isdir(path):
            return  # `path` can sometimes be a directory, see #11334

        visitor = stats.StatisticsVisitor(
            inferred=True,
            filename=tree.fullname,
            modules=modules,
            typemap=type_map,
            all_nodes=True,
        )
        tree.accept(visitor)

        root = etree.Element("mypy-report-file", name=path, module=tree._fullname)
        doc = etree.ElementTree(root)
        file_info = FileInfo(path, tree._fullname)

        for lineno, line_text in iterate_python_lines(path):
            status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
            file_info.counts[status] += 1
            etree.SubElement(
                root,
                "line",
                any_info=self._get_any_info_for_line(visitor, lineno),
                content=line_text.rstrip("\n").translate(self.control_fixer),
                number=str(lineno),
                precision=stats.precision_names[status],
            )
        # Assumes a layout similar to what XmlReporter uses.
        xslt_path = os.path.relpath("mypy-html.xslt", path)
        transform_pi = etree.ProcessingInstruction(
            "xml-stylesheet", f'type="text/xsl" href="{pathname2url(xslt_path)}"'
        )
        root.addprevious(transform_pi)
        self.schema.assertValid(doc)

        self.last_xml = doc
        self.files.append(file_info)

    @staticmethod
    def _get_any_info_for_line(visitor: stats.StatisticsVisitor, lineno: int) -> str:
        if lineno in visitor.any_line_map:
            result = "Any Types on this line: "
            counter: collections.Counter[int] = collections.Counter()
            for typ in visitor.any_line_map[lineno]:
                counter[typ.type_of_any] += 1
            for any_type, occurrences in counter.items():
                result += f"\n{type_of_any_name_map[any_type]} (x{occurrences})"
            return result
        else:
            return "No Anys on this line!"

    def on_finish(self) -> None:
        self.last_xml = None
        # index_path = os.path.join(self.output_dir, 'index.xml')
        output_files = sorted(self.files, key=lambda x: x.module)

        root = etree.Element("mypy-report-index", name="index")
        doc = etree.ElementTree(root)

        for file_info in output_files:
            etree.SubElement(
                root,
                "file",
                file_info.attrib(),
                module=file_info.module,
                name=pathname2url(file_info.name),
                total=str(file_info.total()),
            )
        xslt_path = os.path.relpath("mypy-html.xslt", ".")
        transform_pi = etree.ProcessingInstruction(
            "xml-stylesheet", f'type="text/xsl" href="{pathname2url(xslt_path)}"'
        )
        root.addprevious(transform_pi)
        self.schema.assertValid(doc)

        self.last_xml = doc

register_reporter("memory-xml", MemoryXmlReporter, needs_lxml=True)

def get_line_rate(covered_lines: int, total_lines: int) -> str:
    if total_lines == 0:
        return str(1.0)
    else:
        return f"{covered_lines / total_lines:.4f}"

class CoberturaPackage:
    """Container for XML and statistics mapping python modules to Cobertura package."""

    def __init__(self, name: str) -> None:
        self.name = name
        self.classes: dict[str, Any] = {}
        self.packages: dict[str, CoberturaPackage] = {}
        self.total_lines = 0
        self.covered_lines = 0

    def as_xml(self) -> Any:
        package_element = etree.Element("package", complexity="1.0", name=self.name)
        package_element.attrib["branch-rate"] = "0"
        package_element.attrib["line-rate"] = get_line_rate(self.covered_lines, self.total_lines)
        classes_element = etree.SubElement(package_element, "classes")
        for class_name in sorted(self.classes):
            classes_element.append(self.classes[class_name])
        self.add_packages(package_element)
        return package_element

    def add_packages(self, parent_element: Any) -> None:
        if self.packages:
            packages_element = etree.SubElement(parent_element, "packages")
            for package in sorted(self.packages.values(), key=attrgetter("name")):
                packages_element.append(package.as_xml())

class CoberturaXmlReporter(AbstractReporter):
    """Reporter for generating Cobertura compliant XML."""

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)

        self.root = etree.Element("coverage", timestamp=str(int(time.time())), version=__version__)
        self.doc = etree.ElementTree(self.root)
        self.root_package = CoberturaPackage(".")

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        path = os.path.relpath(tree.path)
        visitor = stats.StatisticsVisitor(
            inferred=True,
            filename=tree.fullname,
            modules=modules,
            typemap=type_map,
            all_nodes=True,
        )
        tree.accept(visitor)

        class_name = os.path.basename(path)
        file_info = FileInfo(path, tree._fullname)
        class_element = etree.Element("class", complexity="1.0", filename=path, name=class_name)
        etree.SubElement(class_element, "methods")
        lines_element = etree.SubElement(class_element, "lines")

        class_lines_covered = 0
        class_total_lines = 0
        for lineno, _ in iterate_python_lines(path):
            status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
            hits = 0
            branch = False
            if status == stats.TYPE_EMPTY:
                continue
            class_total_lines += 1
            if status != stats.TYPE_ANY:
                class_lines_covered += 1
                hits = 1
            if status == stats.TYPE_IMPRECISE:
                branch = True
            file_info.counts[status] += 1
            line_element = etree.SubElement(
                lines_element,
                "line",
                branch=str(branch).lower(),
                hits=str(hits),
                number=str(lineno),
                precision=stats.precision_names[status],
            )
            if branch:
                line_element.attrib["condition-coverage"] = "50% (1/2)"
        class_element.attrib["branch-rate"] = "0"
        class_element.attrib["line-rate"] = get_line_rate(class_lines_covered, class_total_lines)
        # parent_module is set to whichever module contains this file.  For most files, we want
        # to simply strip the last element off of the module.  But for __init__.py files,
        # the module == the parent module.
        parent_module = file_info.module.rsplit(".", 1)[0]
        if file_info.name.endswith("__init__.py"):
            parent_module = file_info.module

        if parent_module not in self.root_package.packages:
            self.root_package.packages[parent_module] = CoberturaPackage(parent_module)
        current_package = self.root_package.packages[parent_module]
        packages_to_update = [self.root_package, current_package]
        for package in packages_to_update:
            package.total_lines += class_total_lines
            package.covered_lines += class_lines_covered
        current_package.classes[class_name] = class_element

    def on_finish(self) -> None:
        self.root.attrib["line-rate"] = get_line_rate(
            self.root_package.covered_lines, self.root_package.total_lines
        )
        self.root.attrib["branch-rate"] = "0"
        self.root.attrib["lines-covered"] = str(self.root_package.covered_lines)
        self.root.attrib["lines-valid"] = str(self.root_package.total_lines)
        sources = etree.SubElement(self.root, "sources")
        source_element = etree.SubElement(sources, "source")
        source_element.text = os.getcwd()
        self.root_package.add_packages(self.root)
        out_path = os.path.join(self.output_dir, "cobertura.xml")
        self.doc.write(out_path, encoding="utf-8", pretty_print=True)
        print("Generated Cobertura report:", os.path.abspath(out_path))

register_reporter("cobertura-xml", CoberturaXmlReporter, needs_lxml=True)

class AbstractXmlReporter(AbstractReporter):
    """Internal abstract class for reporters that work via XML."""

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)

        memory_reporter = reports.add_report("memory-xml", "<memory>")
        assert isinstance(memory_reporter, MemoryXmlReporter)
        # The dependency will be called first.
        self.memory_xml = memory_reporter

class XmlReporter(AbstractXmlReporter):
    """Public reporter that exports XML.

    The produced XML files contain a reference to the absolute path
    of the html transform, so they will be locally viewable in a browser.

    However, there is a bug in Chrome and all other WebKit-based browsers
    that makes it fail from file:// URLs but work on http:// URLs.
    """

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        last_xml = self.memory_xml.last_xml
        if last_xml is None:
            return
        path = os.path.relpath(tree.path)
        if path.startswith(".."):
            return
        out_path = os.path.join(self.output_dir, "xml", path + ".xml")
        os.makedirs(os.path.dirname(out_path), exist_ok=True)
        last_xml.write(out_path, encoding="utf-8")

    def on_finish(self) -> None:
        last_xml = self.memory_xml.last_xml
        assert last_xml is not None
        out_path = os.path.join(self.output_dir, "index.xml")
        out_xslt = os.path.join(self.output_dir, "mypy-html.xslt")
        out_css = os.path.join(self.output_dir, "mypy-html.css")
        last_xml.write(out_path, encoding="utf-8")
        shutil.copyfile(self.memory_xml.xslt_html_path, out_xslt)
        shutil.copyfile(self.memory_xml.css_html_path, out_css)
        print("Generated XML report:", os.path.abspath(out_path))

register_reporter("xml", XmlReporter, needs_lxml=True)

class XsltHtmlReporter(AbstractXmlReporter):
    """Public reporter that exports HTML via XSLT.

    This is slightly different than running `xsltproc` on the .xml files,
    because it passes a parameter to rewrite the links.
    """

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)

        self.xslt_html = etree.XSLT(etree.parse(self.memory_xml.xslt_html_path))
        self.param_html = etree.XSLT.strparam("html")

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        last_xml = self.memory_xml.last_xml
        if last_xml is None:
            return
        path = os.path.relpath(tree.path)
        if path.startswith(".."):
            return
        out_path = os.path.join(self.output_dir, "html", path + ".html")
        os.makedirs(os.path.dirname(out_path), exist_ok=True)
        transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html))
        with open(out_path, "wb") as out_file:
            out_file.write(transformed_html)

    def on_finish(self) -> None:
        last_xml = self.memory_xml.last_xml
        assert last_xml is not None
        out_path = os.path.join(self.output_dir, "index.html")
        out_css = os.path.join(self.output_dir, "mypy-html.css")
        transformed_html = bytes(self.xslt_html(last_xml, ext=self.param_html))
        with open(out_path, "wb") as out_file:
            out_file.write(transformed_html)
        shutil.copyfile(self.memory_xml.css_html_path, out_css)
        print("Generated HTML report (via XSLT):", os.path.abspath(out_path))

register_reporter("xslt-html", XsltHtmlReporter, needs_lxml=True)

class XsltTxtReporter(AbstractXmlReporter):
    """Public reporter that exports TXT via XSLT.

    Currently this only does the summary, not the individual reports.
    """

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)

        self.xslt_txt = etree.XSLT(etree.parse(self.memory_xml.xslt_txt_path))

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        pass

    def on_finish(self) -> None:
        last_xml = self.memory_xml.last_xml
        assert last_xml is not None
        out_path = os.path.join(self.output_dir, "index.txt")
        transformed_txt = bytes(self.xslt_txt(last_xml))
        with open(out_path, "wb") as out_file:
            out_file.write(transformed_txt)
        print("Generated TXT report (via XSLT):", os.path.abspath(out_path))

register_reporter("xslt-txt", XsltTxtReporter, needs_lxml=True)

alias_reporter("xslt-html", "html")
alias_reporter("xslt-txt", "txt")

class LinePrecisionReporter(AbstractReporter):
    """Report per-module line counts for typing precision.

    Each line is classified into one of these categories:

    * precise (fully type checked)
    * imprecise (Any types in a type component, such as List[Any])
    * any (something with an Any type, implicit or explicit)
    * empty (empty line, comment or docstring)
    * unanalyzed (mypy considers line unreachable)

    The meaning of these categories varies slightly depending on
    context.
    """

    def __init__(self, reports: Reports, output_dir: str) -> None:
        super().__init__(reports, output_dir)
        self.files: list[FileInfo] = []

    def on_file(
        self,
        tree: MypyFile,
        modules: dict[str, MypyFile],
        type_map: dict[Expression, Type],
        options: Options,
    ) -> None:
        try:
            path = os.path.relpath(tree.path)
        except ValueError:
            return

        if should_skip_path(path):
            return

        visitor = stats.StatisticsVisitor(
            inferred=True,
            filename=tree.fullname,
            modules=modules,
            typemap=type_map,
            all_nodes=True,
        )
        tree.accept(visitor)

        file_info = FileInfo(path, tree._fullname)
        for lineno, _ in iterate_python_lines(path):
            status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
            file_info.counts[status] += 1

        self.files.append(file_info)

    def on_finish(self) -> None:
        if not self.files:
            # Nothing to do.
            return
        output_files = sorted(self.files, key=lambda x: x.module)
        report_file = os.path.join(self.output_dir, "lineprecision.txt")
        width = max(4, max(len(info.module) for info in output_files))
        titles = ("Lines", "Precise", "Imprecise", "Any", "Empty", "Unanalyzed")
        widths = (width,) + tuple(len(t) for t in titles)
        fmt = "{:%d}  {:%d}  {:%d}  {:%d}  {:%d}  {:%d}  {:%d}\n" % widths
        with open(report_file, "w") as f:
            f.write(fmt.format("Name", *titles))
            f.write("-" * (width + 51) + "\n")
            for file_info in output_files:
                counts = file_info.counts
                f.write(
                    fmt.format(
                        file_info.module.ljust(width),
                        file_info.total(),
                        counts[stats.TYPE_PRECISE],
                        counts[stats.TYPE_IMPRECISE],
                        counts[stats.TYPE_ANY],
                        counts[stats.TYPE_EMPTY],
                        counts[stats.TYPE_UNANALYZED],
                    )
                )

register_reporter("lineprecision", LinePrecisionReporter)

# Reporter class names are defined twice to speed up mypy startup, as this
# module is slow to import. Ensure that the two definitions match.
assert set(reporter_classes) == set(REPORTER_NAMES)


"""Track current scope to easily calculate the corresponding fine-grained target.

TODO: Use everywhere where we track targets, including in mypy.errors.
"""

from __future__ import annotations

from contextlib import contextmanager, nullcontext
from typing import Iterator, Optional, Tuple
from typing_extensions import TypeAlias as _TypeAlias

from mypy.nodes import FuncBase, TypeInfo

SavedScope: _TypeAlias = Tuple[str, Optional[TypeInfo], Optional[FuncBase]]

class Scope:
    """Track which target we are processing at any given time."""

    def __init__(self) -> None:
        self.module: str | None = None
        self.classes: list[TypeInfo] = []
        self.function: FuncBase | None = None
        self.functions: list[FuncBase] = []
        # Number of nested scopes ignored (that don't get their own separate targets)
        self.ignored = 0

    def current_module_id(self) -> str:
        assert self.module
        return self.module

    def current_target(self) -> str:
        """Return the current target (non-class; for a class return enclosing module)."""
        assert self.module
        if self.function:
            fullname = self.function.fullname
            return fullname or ""
        return self.module

    def current_full_target(self) -> str:
        """Return the current target (may be a class)."""
        assert self.module
        if self.function:
            return self.function.fullname
        if self.classes:
            return self.classes[-1].fullname
        return self.module

    def current_type_name(self) -> str | None:
        """Return the current type's short name if it exists"""
        return self.classes[-1].name if self.classes else None

    def current_function_name(self) -> str | None:
        """Return the current function's short name if it exists"""
        return self.function.name if self.function else None

    @contextmanager
    def module_scope(self, prefix: str) -> Iterator[None]:
        self.module = prefix
        self.classes = []
        self.function = None
        self.ignored = 0
        yield
        assert self.module
        self.module = None

    @contextmanager
    def function_scope(self, fdef: FuncBase) -> Iterator[None]:
        self.functions.append(fdef)
        if not self.function:
            self.function = fdef
        else:
            # Nested functions are part of the topmost function target.
            self.ignored += 1
        yield
        self.functions.pop()
        if self.ignored:
            # Leave a scope that's included in the enclosing target.
            self.ignored -= 1
        else:
            assert self.function
            self.function = None

    def outer_functions(self) -> list[FuncBase]:
        return self.functions[:-1]

    def enter_class(self, info: TypeInfo) -> None:
        """Enter a class target scope."""
        if not self.function:
            self.classes.append(info)
        else:
            # Classes within functions are part of the enclosing function target.
            self.ignored += 1

    def leave_class(self) -> None:
        """Leave a class target scope."""
        if self.ignored:
            # Leave a scope that's included in the enclosing target.
            self.ignored -= 1
        else:
            assert self.classes
            # Leave the innermost class.
            self.classes.pop()

    @contextmanager
    def class_scope(self, info: TypeInfo) -> Iterator[None]:
        self.enter_class(info)
        yield
        self.leave_class()

    def save(self) -> SavedScope:
        """Produce a saved scope that can be entered with saved_scope()"""
        assert self.module
        # We only save the innermost class, which is sufficient since
        # the rest are only needed for when classes are left.
        cls = self.classes[-1] if self.classes else None
        return self.module, cls, self.function

    @contextmanager
    def saved_scope(self, saved: SavedScope) -> Iterator[None]:
        module, info, function = saved
        with self.module_scope(module):
            with self.class_scope(info) if info else nullcontext():
                with self.function_scope(function) if function else nullcontext():
                    yield