"""
Debug ORMAdapter calls within ORM runs.

Demos::

    python tools/trace_orm_adapter.py -m pytest \
        test/orm/inheritance/test_polymorphic_rel.py::PolymorphicAliasedJoinsTest::test_primary_eager_aliasing_joinedload

    python tools/trace_orm_adapter.py -m pytest \
        test/orm/test_eager_relations.py::LazyLoadOptSpecificityTest::test_pathed_joinedload_aliased_abs_bcs

    python tools/trace_orm_adapter.py my_test_script.py


The above two tests should spit out a ton of debug output.  If a test or program
has no debug output at all, that's a good thing!  it means ORMAdapter isn't
used for that case.

You can then set a breakpoint at the end of any adapt step:

    python tools/trace_orm_adapter.py -d 10 -m pytest -s \
        test/orm/test_eager_relations.py::LazyLoadOptSpecificityTest::test_pathed_joinedload_aliased_abs_bcs


"""  # noqa: E501

from __future__ import annotations

import argparse
import contextlib
import contextvars
import sys
from typing import TYPE_CHECKING

from sqlalchemy.orm import util


if TYPE_CHECKING:
    from typing import Any
    from typing import List
    from typing import Optional

    from sqlalchemy.sql.elements import ColumnElement


class _ORMAdapterTrace:
    def _locate_col(
        self, col: ColumnElement[Any]
    ) -> Optional[ColumnElement[Any]]:
        with self._tracer("_locate_col") as tracer:
            return tracer(super()._locate_col, col)

    def replace(self, col, _include_singleton_constants: bool = False):
        with self._tracer("replace") as tracer:
            return tracer(super().replace, col)

    _orm_adapter_trace_context = contextvars.ContextVar("_tracer")

    @contextlib.contextmanager
    def _tracer(self, meth):
        adapter = self
        ctx = self._orm_adapter_trace_context.get(
            {"stack": [], "last_depth": 0, "line_no": 0}
        )
        self._orm_adapter_trace_context.set(ctx)

        stack: List[Any] = ctx["stack"]  # type: ignore
        last_depth = len(stack)
        line_no: int = ctx["line_no"]  # type: ignore
        ctx["last_depth"] = last_depth
        stack.append((adapter, meth))
        indent = "    " * last_depth

        if hasattr(adapter, "mapper"):
            adapter_desc = (
                f"{adapter.__class__.__name__}"
                f"({adapter.role.name}, mapper={adapter.mapper})"
            )
        else:
            adapter_desc = f"{adapter.__class__.__name__}({adapter.role.name})"

        def tracer_fn(fn, arg):
            nonlocal line_no

            line_no += 1

            print(f"{indent} {line_no} {adapter_desc}", file=REAL_STDOUT)
            sub_indent = " " * len(f"{line_no} ")

            print(
                f"{indent}{sub_indent} -> "
                f"{meth} {_orm_adapter_trace_print(arg)}",
                file=REAL_STDOUT,
            )
            ctx["line_no"] = line_no
            ret = fn(arg)

            if DEBUG_ADAPT_STEP == line_no:
                breakpoint()

            if ret is arg:
                print(f"{indent} {line_no} <- same object", file=REAL_STDOUT)
            else:
                print(
                    f"{indent} {line_no} <- {_orm_adapter_trace_print(ret)}",
                    file=REAL_STDOUT,
                )

            if last_depth == 0:
                print("", file=REAL_STDOUT)
            return ret

        try:
            yield tracer_fn
        finally:
            stack.pop(-1)


util.ORMAdapter.__bases__ = (_ORMAdapterTrace,) + util.ORMAdapter.__bases__
util.ORMStatementAdapter.__bases__ = (
    _ORMAdapterTrace,
) + util.ORMStatementAdapter.__bases__


def _orm_adapter_trace_print(obj):
    if obj is None:
        return "None"

    t_print = _orm_adapter_trace_printers.get(obj.__visit_name__, None)
    if t_print:
        return t_print(obj)
    else:
        return f"{obj!r}"


_orm_adapter_trace_printers = {
    "table": lambda t: (
        f'Table("{t.name}", '
        f"entity={t._annotations.get('parentmapper', None)})"
    ),
    "column": lambda c: (
        f'Column("{c.name}", {_orm_adapter_trace_print(c.table)} '
        f"entity={c._annotations.get('parentmapper', None)})"
    ),
    "join": lambda j: (
        f"{j.__class__.__name__}({_orm_adapter_trace_print(j.left)}, "
        f"{_orm_adapter_trace_print(j.right)})"
    ),
    "label": lambda l: f"Label({_orm_adapter_trace_print(l.element)})",
}

DEBUG_ADAPT_STEP = None
REAL_STDOUT = sys.__stdout__


def main():
    global DEBUG_ADAPT_STEP

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-d", "--debug", type=int, help="breakpoint at this adaptation step"
    )
    parser.add_argument(
        "-m",
        "--module",
        type=str,
        help="import module name instead of running a script",
    )
    parser.add_argument(
        "args", metavar="N", type=str, nargs="*", help="additional arguments"
    )

    argparse_args = []
    sys_argv = list(sys.argv)

    progname = sys_argv.pop(0)

    # this is a little crazy, works at the moment for:
    # module w args:
    #    python tools/trace_orm_adapter.py -m  pytest test/orm/test_query.py -s
    # script:
    #   python tools/trace_orm_adapter.py test3.py
    has_module = False
    while sys_argv:
        arg = sys_argv.pop(0)
        if arg in ("-m", "--module", "-d", "--debug"):
            argparse_args.append(arg)
            argparse_args.append(sys_argv.pop(0))
            has_module = arg in ("-m", "--module")
        else:
            if not has_module:
                argparse_args.append(arg)
            else:
                sys_argv.insert(0, arg)
            break

    options = parser.parse_args(argparse_args)
    sys.argv = ["program.py"] + sys_argv

    if options.module == "pytest":
        sys.argv.extend(["--capture", "sys"])

    import runpy

    if options.debug:
        DEBUG_ADAPT_STEP = options.debug

    if options.module:
        runpy.run_module(options.module, run_name="__main__")
    else:
        progname = options.args[0]

        runpy.run_path(progname)


if __name__ == "__main__":
    main()
