#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

__all__ = (
    "main",
)

r"""
This utility is a special purpose tool to narrow down the cause of errors
(typically crashes or asserts) in scripts generated by ``bl_run_operators.py``.

Minimize a script by commenting out ``run_op(...)`` lines while preserving an exit code.

It simply takes the command that fails and the script, then comments out operators
as long as the error persists. If the operator *was* needed, it's left in.

Example usage::

   ./tests/utils/bl_run_operators_isolate.py \
       --command "blender -b -X -P -- generated_script.py" \
       --exit-code 139 \
       --script generated_script.py
"""
import argparse
import os
import shlex
import subprocess
import sys

from collections.abc import (
    Sequence,
)


# The prefix that identifies lines to be isolated.
RUN_OP_PREFIX = "run_op("

# The prefix for the function to load a new file (session).
CTX_PREFIX = "ctx_"


def trim_to_last_session(lines: list[str]) -> list[str]:
    # Drop all but the last session,
    # from many "sessions" each formatted:
    #
    #    ctx_scene_reset(...)
    #    run_op(...)
    #    run_op(...)
    #
    # Only pick the last, see `--all-sessions` help text for why this is a good default.
    ctx_indices = [i for i, line in enumerate(lines) if line.startswith(CTX_PREFIX)]
    if len(ctx_indices) < 2:
        # Zero or one session: nothing earlier to drop.
        return lines
    return lines[:ctx_indices[0]] + lines[ctx_indices[-1]:]


def run_command(command: str, verbose: bool) -> int:
    """Run ``command`` and return its exit code, showing its output when ``verbose``."""
    if verbose:
        proc = subprocess.run(shlex.split(command))
    else:
        proc = subprocess.run(shlex.split(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    returncode = proc.returncode
    # A signal-killed process has a negative `returncode`,
    # normalize it - as this is what the user will see in the console.
    if returncode < 0:
        returncode = 128 - returncode
    return returncode


def is_run_op_line(line: str) -> bool:
    return line.startswith(RUN_OP_PREFIX)


def comment_line(line: str) -> str:
    return "# {:s}".format(line)


def argparse_create() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        formatter_class=argparse.RawTextHelpFormatter,
        description=__doc__,
    )
    parser.add_argument(
        "--command",
        dest="command",
        required=True,
        help="The command to run (it should execute the script).",
    )
    parser.add_argument(
        "--exit-code",
        dest="exit_code",
        type=int,
        required=True,
        help="The exit code to preserve.",
    )
    parser.add_argument(
        "--script",
        dest="script",
        required=True,
        help="The script file whose run_op(...) lines are isolated (modified in-place).",
    )
    parser.add_argument(
        "--keep-comments",
        dest="keep_comments",
        action="store_true",
        default=False,
        help="Leave commented out lines in place, otherwise they are removed once complete.",
    )
    parser.add_argument(
        "--all-sessions",
        dest="all_sessions",
        action="store_true",
        default=False,
        help="Keep all sessions; by default only the last (after the final ctx_ line) is kept.",
    )
    parser.add_argument(
        "--verbose",
        dest="verbose",
        action="store_true",
        default=False,
        help="Show the full output of each command run.",
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    if argv is None:
        argv = sys.argv[1:]

    args = argparse_create().parse_args(argv)

    command = args.command
    exit_code_expect = args.exit_code
    script = args.script
    keep_comments = args.keep_comments
    all_sessions = args.all_sessions
    verbose = args.verbose

    with open(script, "r", encoding="utf-8") as fh:
        lines = fh.readlines()

    # Keep a backup in case rewriting loses data.
    script_backup = script + ".backup"
    if os.path.exists(script_backup):
        print("Backup {:s} already exists, keeping it.".format(script_backup))
    else:
        with open(script_backup, "w", encoding="utf-8") as fh:
            fh.writelines(lines)
        print("Backed up original script to {:s}.".format(script_backup))

    if not all_sessions:
        lines_trimmed = trim_to_last_session(lines)
        if len(lines_trimmed) != len(lines):
            lines = lines_trimmed
            with open(script, "w", encoding="utf-8") as fh:
                fh.writelines(lines)
            print("Trimmed to the last session ({:d} lines).".format(len(lines)))

    # Ensure the given command generates the expected exit-code.
    print("Baseline run, expecting exit-code {:d}...".format(exit_code_expect))
    exit_code = run_command(command, verbose)
    if exit_code != exit_code_expect:
        print("Baseline exit-code was {:d}, expected {:d}, aborting.".format(exit_code, exit_code_expect))
        return 1
    print("Baseline OK.")

    # The total number of run_op(...) lines (only used for the final summary).
    run_op_total = sum(1 for line in lines if is_run_op_line(line))
    print("Found {:d} run_op(...) lines to isolate.".format(run_op_total))

    # Comment lines which aren't needed to redo the error,
    # afterwards, all of them are removed.
    commented_indices: set[int] = set()
    pass_number = 0
    while True:
        pass_number += 1
        commented_this_pass = 0

        # Re-scan each pass: every still-active `run_op(...)` line is a candidate again.
        candidates = [i for i, line in enumerate(lines) if is_run_op_line(line)]
        if not candidates:
            break

        print("Pass {:d}: {:d} candidate run_op(...) lines.".format(pass_number, len(candidates)))
        for i in candidates:
            line_orig = lines[i]
            lines[i] = comment_line(line_orig)
            with open(script, "w", encoding="utf-8") as fh:
                fh.writelines(lines)

            # In verbose mode the command's output follows, so end the line first.
            print("Line {:d}: trying commented... ".format(i + 1), end="\n" if verbose else "", flush=True)
            exit_code = run_command(command, verbose)
            if exit_code == exit_code_expect:
                commented_indices.add(i)
                commented_this_pass += 1
                print("still exit-code {:d}, leaving commented.".format(exit_code_expect))
            else:
                lines[i] = line_orig
                with open(script, "w", encoding="utf-8") as fh:
                    fh.writelines(lines)
                print("exit-code changed to {:d}, restoring.".format(exit_code))

        if commented_this_pass == 0:
            break

    # Unless requested, remove the commented out lines entirely.
    if commented_indices and not keep_comments:
        lines = [line for i, line in enumerate(lines) if i not in commented_indices]

    # Ensure the final state is written to disk.
    with open(script, "w", encoding="utf-8") as fh:
        fh.writelines(lines)

    print("Done: commented {:d} of {:d} run_op(...) lines.".format(len(commented_indices), run_op_total))
    return 0


if __name__ == "__main__":
    sys.exit(main())
