"""Conversion of parse tree nodes to strings."""

from __future__ import annotations

import os
import re
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

import mypy.nodes
from mypy.options import Options
from mypy.util import IdMapper, short_type
from mypy.visitor import NodeVisitor

if TYPE_CHECKING:
    import mypy.patterns
    import mypy.types


class StrConv(NodeVisitor[str]):
    """Visitor for converting a node to a human-readable string.

    For example, an MypyFile node from program '1' is converted into
    something like this:

      MypyFile:1(
        fnam
        ExpressionStmt:1(
          IntExpr(1)))
    """

    __slots__ = ["options", "show_ids", "id_mapper"]

    def __init__(self, *, show_ids: bool = False, options: Options) -> None:
        self.options = options
        self.show_ids = show_ids
        self.id_mapper: IdMapper | None = None
        if show_ids:
            self.id_mapper = IdMapper()

    def stringify_type(self, t: mypy.types.Type) -> str:
        import mypy.types

        return t.accept(mypy.types.TypeStrVisitor(id_mapper=self.id_mapper, options=self.options))

    def get_id(self, o: object) -> int | None:
        if self.id_mapper:
            return self.id_mapper.id(o)
        return None

    def format_id(self, o: object) -> str:
        if self.id_mapper:
            return f"<{self.get_id(o)}>"
        else:
            return ""

    def dump(self, nodes: Sequence[object], obj: mypy.nodes.Context) -> str:
        """Convert a list of items to a multiline pretty-printed string.

        The tag is produced from the type name of obj and its line
        number. See mypy.util.dump_tagged for a description of the nodes
        argument.
        """
        tag = short_type(obj) + ":" + str(obj.line)
        if self.show_ids:
            assert self.id_mapper is not None
            tag += f"<{self.get_id(obj)}>"
        return dump_tagged(nodes, tag, self)

    def func_helper(self, o: mypy.nodes.FuncItem) -> list[object]:
        """Return a list in a format suitable for dump() that represents the
        arguments and the body of a function. The caller can then decorate the
        array with information specific to methods, global functions or
        anonymous functions.
        """
        args: list[mypy.nodes.Var | tuple[str, list[mypy.nodes.Node]]] = []
        extra: list[tuple[str, list[mypy.nodes.Var]]] = []
        for arg in o.arguments:
            kind: mypy.nodes.ArgKind = arg.kind
            if kind.is_required():
                args.append(arg.variable)
            elif kind.is_optional():
                assert arg.initializer is not None
                args.append(("default", [arg.variable, arg.initializer]))
            elif kind == mypy.nodes.ARG_STAR:
                extra.append(("VarArg", [arg.variable]))
            elif kind == mypy.nodes.ARG_STAR2:
                extra.append(("DictVarArg", [arg.variable]))
        a: list[Any] = []
        if o.type_args:
            for p in o.type_args:
                a.append(self.type_param(p))
        if args:
            a.append(("Args", args))
        if o.type:
            a.append(o.type)
        if o.is_generator:
            a.append("Generator")
        a.extend(extra)
        a.append(o.body)
        return a

    # Top-level structures

    def visit_mypy_file(self, o: mypy.nodes.MypyFile) -> str:
        # Skip implicit definitions.
        a: list[Any] = [o.defs]
        if o.is_bom:
            a.insert(0, "BOM")
        # Omit path to special file with name "main". This is used to simplify
        # test case descriptions; the file "main" is used by default in many
        # test cases.
        if o.path != "main":
            # Insert path. Normalize directory separators to / to unify test
            # case# output in all platforms.
            a.insert(0, o.path.replace(os.getcwd() + os.sep, "").replace(os.sep, "/"))
        if o.ignored_lines:
            a.append("IgnoredLines(%s)" % ", ".join(str(line) for line in sorted(o.ignored_lines)))
        return self.dump(a, o)

    def visit_import(self, o: mypy.nodes.Import) -> str:
        a = []
        for id, as_id in o.ids:
            if as_id is not None:
                a.append(f"{id} : {as_id}")
            else:
                a.append(id)
        return f"Import:{o.line}({', '.join(a)})"

    def visit_import_from(self, o: mypy.nodes.ImportFrom) -> str:
        a = []
        for name, as_name in o.names:
            if as_name is not None:
                a.append(f"{name} : {as_name}")
            else:
                a.append(name)
        return f"ImportFrom:{o.line}({'.' * o.relative + o.id}, [{', '.join(a)}])"

    def visit_import_all(self, o: mypy.nodes.ImportAll) -> str:
        return f"ImportAll:{o.line}({'.' * o.relative + o.id})"

    # Definitions

    def visit_func_def(self, o: mypy.nodes.FuncDef) -> str:
        a = self.func_helper(o)
        a.insert(0, o.name)
        arg_kinds = {arg.kind for arg in o.arguments}
        if len(arg_kinds & {mypy.nodes.ARG_NAMED, mypy.nodes.ARG_NAMED_OPT}) > 0:
            a.insert(1, f"MaxPos({o.max_pos})")
        if o.abstract_status in (mypy.nodes.IS_ABSTRACT, mypy.nodes.IMPLICITLY_ABSTRACT):
            a.insert(-1, "Abstract")
        if o.is_static:
            a.insert(-1, "Static")
        if o.is_class:
            a.insert(-1, "Class")
        if o.is_property:
            a.insert(-1, "Property")
        return self.dump(a, o)

    def visit_overloaded_func_def(self, o: mypy.nodes.OverloadedFuncDef) -> str:
        a: Any = o.items.copy()
        if o.type:
            a.insert(0, o.type)
        if o.impl:
            a.insert(0, o.impl)
        if o.is_static:
            a.insert(-1, "Static")
        if o.is_class:
            a.insert(-1, "Class")
        return self.dump(a, o)

    def visit_class_def(self, o: mypy.nodes.ClassDef) -> str:
        a = [o.name, o.defs.body]
        # Display base types unless they are implicitly just builtins.object
        # (in this case base_type_exprs is empty).
        if o.base_type_exprs:
            if o.info and o.info.bases:
                if len(o.info.bases) != 1 or o.info.bases[0].type.fullname != "builtins.object":
                    a.insert(1, ("BaseType", o.info.bases))
            else:
                a.insert(1, ("BaseTypeExpr", o.base_type_exprs))
        if o.type_vars:
            a.insert(1, ("TypeVars", o.type_vars))
        if o.metaclass:
            a.insert(1, f"Metaclass({o.metaclass.accept(self)})")
        if o.decorators:
            a.insert(1, ("Decorators", o.decorators))
        if o.info and o.info._promote:
            a.insert(1, f"Promote([{','.join(self.stringify_type(p) for p in o.info._promote)}])")
        if o.info and o.info.tuple_type:
            a.insert(1, ("TupleType", [o.info.tuple_type]))
        if o.info and o.info.fallback_to_any:
            a.insert(1, "FallbackToAny")
        if o.type_args:
            for p in reversed(o.type_args):
                a.insert(1, self.type_param(p))
        return self.dump(a, o)

    def visit_var(self, o: mypy.nodes.Var) -> str:
        lst = ""
        # Add :nil line number tag if no line number is specified to remain
        # compatible with old test case descriptions that assume this.
        if o.line < 0:
            lst = ":nil"
        return "Var" + lst + "(" + o.name + ")"

    def visit_global_decl(self, o: mypy.nodes.GlobalDecl) -> str:
        return self.dump([o.names], o)

    def visit_nonlocal_decl(self, o: mypy.nodes.NonlocalDecl) -> str:
        return self.dump([o.names], o)

    def visit_decorator(self, o: mypy.nodes.Decorator) -> str:
        return self.dump([o.var, o.decorators, o.func], o)

    def visit_type_alias(self, o: mypy.nodes.TypeAlias, /) -> str:
        return self.dump([o.name, o.target, o.alias_tvars, o.no_args], o)

    def visit_placeholder_node(self, o: mypy.nodes.PlaceholderNode, /) -> str:
        return self.dump([o.fullname], o)

    # Statements

    def visit_block(self, o: mypy.nodes.Block) -> str:
        return self.dump(o.body, o)

    def visit_expression_stmt(self, o: mypy.nodes.ExpressionStmt) -> str:
        return self.dump([o.expr], o)

    def visit_assignment_stmt(self, o: mypy.nodes.AssignmentStmt) -> str:
        a: list[Any] = []
        if len(o.lvalues) > 1:
            a = [("Lvalues", o.lvalues)]
        else:
            a = [o.lvalues[0]]
        a.append(o.rvalue)
        if o.type:
            a.append(o.type)
        return self.dump(a, o)

    def visit_operator_assignment_stmt(self, o: mypy.nodes.OperatorAssignmentStmt) -> str:
        return self.dump([o.op, o.lvalue, o.rvalue], o)

    def visit_while_stmt(self, o: mypy.nodes.WhileStmt) -> str:
        a: list[Any] = [o.expr, o.body]
        if o.else_body:
            a.append(("Else", o.else_body.body))
        return self.dump(a, o)

    def visit_for_stmt(self, o: mypy.nodes.ForStmt) -> str:
        a: list[Any] = []
        if o.is_async:
            a.append(("Async", ""))
        a.append(o.index)
        if o.index_type:
            a.append(o.index_type)
        a.extend([o.expr, o.body])
        if o.else_body:
            a.append(("Else", o.else_body.body))
        return self.dump(a, o)

    def visit_return_stmt(self, o: mypy.nodes.ReturnStmt) -> str:
        return self.dump([o.expr], o)

    def visit_if_stmt(self, o: mypy.nodes.IfStmt) -> str:
        a: list[Any] = []
        for i in range(len(o.expr)):
            a.append(("If", [o.expr[i]]))
            a.append(("Then", o.body[i].body))

        if not o.else_body:
            return self.dump(a, o)
        else:
            return self.dump([a, ("Else", o.else_body.body)], o)

    def visit_break_stmt(self, o: mypy.nodes.BreakStmt) -> str:
        return self.dump([], o)

    def visit_continue_stmt(self, o: mypy.nodes.ContinueStmt) -> str:
        return self.dump([], o)

    def visit_pass_stmt(self, o: mypy.nodes.PassStmt) -> str:
        return self.dump([], o)

    def visit_raise_stmt(self, o: mypy.nodes.RaiseStmt) -> str:
        return self.dump([o.expr, o.from_expr], o)

    def visit_assert_stmt(self, o: mypy.nodes.AssertStmt) -> str:
        if o.msg is not None:
            return self.dump([o.expr, o.msg], o)
        else:
            return self.dump([o.expr], o)

    def visit_await_expr(self, o: mypy.nodes.AwaitExpr) -> str:
        return self.dump([o.expr], o)

    def visit_del_stmt(self, o: mypy.nodes.DelStmt) -> str:
        return self.dump([o.expr], o)

    def visit_try_stmt(self, o: mypy.nodes.TryStmt) -> str:
        a: list[Any] = [o.body]
        if o.is_star:
            a.append("*")

        for i in range(len(o.vars)):
            a.append(o.types[i])
            if o.vars[i]:
                a.append(o.vars[i])
            a.append(o.handlers[i])

        if o.else_body:
            a.append(("Else", o.else_body.body))
        if o.finally_body:
            a.append(("Finally", o.finally_body.body))

        return self.dump(a, o)

    def visit_with_stmt(self, o: mypy.nodes.WithStmt) -> str:
        a: list[Any] = []
        if o.is_async:
            a.append(("Async", ""))
        for i in range(len(o.expr)):
            a.append(("Expr", [o.expr[i]]))
            if o.target[i]:
                a.append(("Target", [o.target[i]]))
        if o.unanalyzed_type:
            a.append(o.unanalyzed_type)
        return self.dump(a + [o.body], o)

    def visit_match_stmt(self, o: mypy.nodes.MatchStmt) -> str:
        a: list[Any] = [o.subject]
        for i in range(len(o.patterns)):
            a.append(("Pattern", [o.patterns[i]]))
            if o.guards[i] is not None:
                a.append(("Guard", [o.guards[i]]))
            a.append(("Body", o.bodies[i].body))
        return self.dump(a, o)

    def visit_type_alias_stmt(self, o: mypy.nodes.TypeAliasStmt) -> str:
        a: list[Any] = [o.name]
        for p in o.type_args:
            a.append(self.type_param(p))
        a.append(o.value)
        return self.dump(a, o)

    def type_param(self, p: mypy.nodes.TypeParam) -> list[Any]:
        a: list[Any] = []
        if p.kind == mypy.nodes.PARAM_SPEC_KIND:
            prefix = "**"
        elif p.kind == mypy.nodes.TYPE_VAR_TUPLE_KIND:
            prefix = "*"
        else:
            prefix = ""
        a.append(prefix + p.name)
        if p.upper_bound:
            a.append(p.upper_bound)
        if p.values:
            a.append(("Values", p.values))
        if p.default:
            a.append(("Default", [p.default]))
        return [("TypeParam", a)]

    # Expressions

    # Simple expressions

    def visit_int_expr(self, o: mypy.nodes.IntExpr) -> str:
        return f"IntExpr({o.value})"

    def visit_str_expr(self, o: mypy.nodes.StrExpr) -> str:
        return f"StrExpr({self.str_repr(o.value)})"

    def visit_bytes_expr(self, o: mypy.nodes.BytesExpr) -> str:
        return f"BytesExpr({self.str_repr(o.value)})"

    def str_repr(self, s: str) -> str:
        s = re.sub(r"\\u[0-9a-fA-F]{4}", lambda m: "\\" + m.group(0), s)
        return re.sub("[^\\x20-\\x7e]", lambda m: r"\u%.4x" % ord(m.group(0)), s)

    def visit_float_expr(self, o: mypy.nodes.FloatExpr) -> str:
        return f"FloatExpr({o.value})"

    def visit_complex_expr(self, o: mypy.nodes.ComplexExpr) -> str:
        return f"ComplexExpr({o.value})"

    def visit_ellipsis(self, o: mypy.nodes.EllipsisExpr) -> str:
        return "Ellipsis"

    def visit_star_expr(self, o: mypy.nodes.StarExpr) -> str:
        return self.dump([o.expr], o)

    def visit_name_expr(self, o: mypy.nodes.NameExpr) -> str:
        pretty = self.pretty_name(
            o.name, o.kind, o.fullname, o.is_inferred_def or o.is_special_form, o.node
        )
        if isinstance(o.node, mypy.nodes.Var) and o.node.is_final:
            final_value = o.node.final_value
            if final_value is not None:
                pretty += f" = {o.node.final_value}"
        return short_type(o) + "(" + pretty + ")"

    def pretty_name(
        self,
        name: str,
        kind: int | None,
        fullname: str | None,
        is_inferred_def: bool,
        target_node: mypy.nodes.Node | None = None,
    ) -> str:
        n = name
        if is_inferred_def:
            n += "*"
        if target_node:
            id = self.format_id(target_node)
        else:
            id = ""
        if isinstance(target_node, mypy.nodes.MypyFile) and name == fullname:
            n += id
        elif kind == mypy.nodes.GDEF or (fullname != name and fullname):
            # Append fully qualified name for global references.
            n += f" [{fullname}{id}]"
        elif kind == mypy.nodes.LDEF:
            # Add tag to signify a local reference.
            n += f" [l{id}]"
        elif kind == mypy.nodes.MDEF:
            # Add tag to signify a member reference.
            n += f" [m{id}]"
        else:
            n += id
        return n

    def visit_member_expr(self, o: mypy.nodes.MemberExpr) -> str:
        pretty = self.pretty_name(o.name, o.kind, o.fullname, o.is_inferred_def, o.node)
        return self.dump([o.expr, pretty], o)

    def visit_yield_expr(self, o: mypy.nodes.YieldExpr) -> str:
        return self.dump([o.expr], o)

    def visit_yield_from_expr(self, o: mypy.nodes.YieldFromExpr) -> str:
        if o.expr:
            return self.dump([o.expr.accept(self)], o)
        else:
            return self.dump([], o)

    def visit_call_expr(self, o: mypy.nodes.CallExpr) -> str:
        if o.analyzed:
            return o.analyzed.accept(self)
        args: list[mypy.nodes.Expression] = []
        extra: list[str | tuple[str, list[Any]]] = []
        for i, kind in enumerate(o.arg_kinds):
            if kind in [mypy.nodes.ARG_POS, mypy.nodes.ARG_STAR]:
                args.append(o.args[i])
                if kind == mypy.nodes.ARG_STAR:
                    extra.append("VarArg")
            elif kind == mypy.nodes.ARG_NAMED:
                extra.append(("KwArgs", [o.arg_names[i], o.args[i]]))
            elif kind == mypy.nodes.ARG_STAR2:
                extra.append(("DictVarArg", [o.args[i]]))
            else:
                raise RuntimeError(f"unknown kind {kind}")
        a: list[Any] = [o.callee, ("Args", args)]
        return self.dump(a + extra, o)

    def visit_op_expr(self, o: mypy.nodes.OpExpr) -> str:
        if o.analyzed:
            return o.analyzed.accept(self)
        return self.dump([o.op, o.left, o.right], o)

    def visit_comparison_expr(self, o: mypy.nodes.ComparisonExpr) -> str:
        return self.dump([o.operators, o.operands], o)

    def visit_cast_expr(self, o: mypy.nodes.CastExpr) -> str:
        return self.dump([o.expr, o.type], o)

    def visit_type_form_expr(self, o: mypy.nodes.TypeFormExpr) -> str:
        return self.dump([o.type], o)

    def visit_assert_type_expr(self, o: mypy.nodes.AssertTypeExpr) -> str:
        return self.dump([o.expr, o.type], o)

    def visit_reveal_expr(self, o: mypy.nodes.RevealExpr) -> str:
        if o.kind == mypy.nodes.REVEAL_TYPE:
            return self.dump([o.expr], o)
        else:
            # REVEAL_LOCALS
            return self.dump([o.local_nodes], o)

    def visit_assignment_expr(self, o: mypy.nodes.AssignmentExpr) -> str:
        return self.dump([o.target, o.value], o)

    def visit_unary_expr(self, o: mypy.nodes.UnaryExpr) -> str:
        return self.dump([o.op, o.expr], o)

    def visit_list_expr(self, o: mypy.nodes.ListExpr) -> str:
        return self.dump(o.items, o)

    def visit_dict_expr(self, o: mypy.nodes.DictExpr) -> str:
        return self.dump([[k, v] for k, v in o.items], o)

    def visit_set_expr(self, o: mypy.nodes.SetExpr) -> str:
        return self.dump(o.items, o)

    def visit_tuple_expr(self, o: mypy.nodes.TupleExpr) -> str:
        return self.dump(o.items, o)

    def visit_index_expr(self, o: mypy.nodes.IndexExpr) -> str:
        if o.analyzed:
            return o.analyzed.accept(self)
        return self.dump([o.base, o.index], o)

    def visit_super_expr(self, o: mypy.nodes.SuperExpr) -> str:
        return self.dump([o.name, o.call], o)

    def visit_type_application(self, o: mypy.nodes.TypeApplication) -> str:
        return self.dump([o.expr, ("Types", o.types)], o)

    def visit_type_var_expr(self, o: mypy.nodes.TypeVarExpr) -> str:
        import mypy.types

        a: list[Any] = []
        if o.variance == mypy.nodes.COVARIANT:
            a += ["Variance(COVARIANT)"]
        if o.variance == mypy.nodes.CONTRAVARIANT:
            a += ["Variance(CONTRAVARIANT)"]
        if o.values:
            a += [("Values", o.values)]
        if not mypy.types.is_named_instance(o.upper_bound, "builtins.object"):
            a += [f"UpperBound({self.stringify_type(o.upper_bound)})"]
        return self.dump(a, o)

    def visit_paramspec_expr(self, o: mypy.nodes.ParamSpecExpr) -> str:
        import mypy.types

        a: list[Any] = []
        if o.variance == mypy.nodes.COVARIANT:
            a += ["Variance(COVARIANT)"]
        if o.variance == mypy.nodes.CONTRAVARIANT:
            a += ["Variance(CONTRAVARIANT)"]
        if not mypy.types.is_named_instance(o.upper_bound, "builtins.object"):
            a += [f"UpperBound({self.stringify_type(o.upper_bound)})"]
        return self.dump(a, o)

    def visit_type_var_tuple_expr(self, o: mypy.nodes.TypeVarTupleExpr) -> str:
        import mypy.types

        a: list[Any] = []
        if o.variance == mypy.nodes.COVARIANT:
            a += ["Variance(COVARIANT)"]
        if o.variance == mypy.nodes.CONTRAVARIANT:
            a += ["Variance(CONTRAVARIANT)"]
        if not mypy.types.is_named_instance(o.upper_bound, "builtins.object"):
            a += [f"UpperBound({self.stringify_type(o.upper_bound)})"]
        return self.dump(a, o)

    def visit_type_alias_expr(self, o: mypy.nodes.TypeAliasExpr) -> str:
        return f"TypeAliasExpr({self.stringify_type(o.node.target)})"

    def visit_namedtuple_expr(self, o: mypy.nodes.NamedTupleExpr) -> str:
        return f"NamedTupleExpr:{o.line}({o.info.name}, {self.stringify_type(o.info.tuple_type) if o.info.tuple_type is not None else None})"

    def visit_enum_call_expr(self, o: mypy.nodes.EnumCallExpr) -> str:
        return f"EnumCallExpr:{o.line}({o.info.name}, {o.items})"

    def visit_typeddict_expr(self, o: mypy.nodes.TypedDictExpr) -> str:
        return f"TypedDictExpr:{o.line}({o.info.name})"

    def visit__promote_expr(self, o: mypy.nodes.PromoteExpr) -> str:
        return f"PromoteExpr:{o.line}({self.stringify_type(o.type)})"

    def visit_newtype_expr(self, o: mypy.nodes.NewTypeExpr) -> str:
        return f"NewTypeExpr:{o.line}({o.name}, {self.dump([o.old_type], o)})"

    def visit_lambda_expr(self, o: mypy.nodes.LambdaExpr) -> str:
        a = self.func_helper(o)
        return self.dump(a, o)

    def visit_generator_expr(self, o: mypy.nodes.GeneratorExpr) -> str:
        condlists = o.condlists if any(o.condlists) else None
        return self.dump([o.left_expr, o.indices, o.sequences, condlists], o)

    def visit_list_comprehension(self, o: mypy.nodes.ListComprehension) -> str:
        return self.dump([o.generator], o)

    def visit_set_comprehension(self, o: mypy.nodes.SetComprehension) -> str:
        return self.dump([o.generator], o)

    def visit_dictionary_comprehension(self, o: mypy.nodes.DictionaryComprehension) -> str:
        condlists = o.condlists if any(o.condlists) else None
        return self.dump([o.key, o.value, o.indices, o.sequences, condlists], o)

    def visit_conditional_expr(self, o: mypy.nodes.ConditionalExpr) -> str:
        return self.dump([("Condition", [o.cond]), o.if_expr, o.else_expr], o)

    def visit_slice_expr(self, o: mypy.nodes.SliceExpr) -> str:
        a: list[Any] = [o.begin_index, o.end_index, o.stride]
        if not a[0]:
            a[0] = "<empty>"
        if not a[1]:
            a[1] = "<empty>"
        return self.dump(a, o)

    def visit_temp_node(self, o: mypy.nodes.TempNode) -> str:
        return self.dump([o.type], o)

    def visit_as_pattern(self, o: mypy.patterns.AsPattern) -> str:
        return self.dump([o.pattern, o.name], o)

    def visit_or_pattern(self, o: mypy.patterns.OrPattern) -> str:
        return self.dump(o.patterns, o)

    def visit_value_pattern(self, o: mypy.patterns.ValuePattern) -> str:
        return self.dump([o.expr], o)

    def visit_singleton_pattern(self, o: mypy.patterns.SingletonPattern) -> str:
        return self.dump([o.value], o)

    def visit_sequence_pattern(self, o: mypy.patterns.SequencePattern) -> str:
        return self.dump(o.patterns, o)

    def visit_starred_pattern(self, o: mypy.patterns.StarredPattern) -> str:
        return self.dump([o.capture], o)

    def visit_mapping_pattern(self, o: mypy.patterns.MappingPattern) -> str:
        a: list[Any] = []
        for i in range(len(o.keys)):
            a.append(("Key", [o.keys[i]]))
            a.append(("Value", [o.values[i]]))
        if o.rest is not None:
            a.append(("Rest", [o.rest]))
        return self.dump(a, o)

    def visit_class_pattern(self, o: mypy.patterns.ClassPattern) -> str:
        a: list[Any] = [o.class_ref]
        if len(o.positionals) > 0:
            a.append(("Positionals", o.positionals))
        for i in range(len(o.keyword_keys)):
            a.append(("Keyword", [o.keyword_keys[i], o.keyword_values[i]]))

        return self.dump(a, o)


def dump_tagged(nodes: Sequence[object], tag: str | None, str_conv: StrConv) -> str:
    """Convert an array into a pretty-printed multiline string representation.

    The format is
      tag(
        item1..
        itemN)
    Individual items are formatted like this:
     - arrays are flattened
     - pairs (str, array) are converted recursively, so that str is the tag
     - other items are converted to strings and indented
    """
    from mypy.types import Type, TypeStrVisitor

    a: list[str] = []
    if tag:
        a.append(tag + "(")
    for n in nodes:
        if isinstance(n, list):
            if n:
                a.append(dump_tagged(n, None, str_conv))
        elif isinstance(n, tuple):
            s = dump_tagged(n[1], n[0], str_conv)
            a.append(indent(s, 2))
        elif isinstance(n, mypy.nodes.Node):
            a.append(indent(n.accept(str_conv), 2))
        elif isinstance(n, Type):
            a.append(
                indent(n.accept(TypeStrVisitor(str_conv.id_mapper, options=str_conv.options)), 2)
            )
        elif n is not None:
            a.append(indent(str(n), 2))
    if tag:
        a[-1] += ")"
    return "\n".join(a)


def indent(s: str, n: int) -> str:
    """Indent all the lines in s (separated by newlines) by n spaces."""
    s = " " * n + s
    s = s.replace("\n", "\n" + " " * n)
    return s
