# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 InterGenJLU
"""Step 11 Forge regression — assert the build-pipeline lift is invisible to Forge.

This test verifies the final guard for the build-pipeline debug-logger lift
(dossier intergenos-private/research/2026-05-28-build-pipeline-debug-logging/
50-implementation-priority.md Step 11):

  The lift uses a shim approach for installer/backend/trace.py so Forge stays
  bit-identical from a behavior perspective. We verify this by:

    1. Importing every public name Forge's trace.py used to expose, asserting
       the import still works.
    2. Asserting installer.backend.trace's public surface is a superset of
       Forge's pre-lift API.
    3. Asserting FORGE_DEBUG_VERBOSE continues to gate verbose-mode opt-in
       (preserved alongside IGOS_BUILD_DEBUG_VERBOSE).
    4. Asserting traced_run + init_trace + attach_target_sink + close_trace +
       trace_install_step + install_failure all still exist and have the same
       call signatures (function names + parameter counts).
    5. Smoke-running a synthetic traced_run + trace_install_step + install_failure
       sequence with FORGE_DEBUG_VERBOSE=1 and asserting the JSONL events that
       drop have the same shape Forge's prior-art module emitted (verified by
       inspecting installer/backend/trace.py at commit 5ae89d5d via the shim).

If this test fails after the lift, the lift broke Forge backward-compat and
needs revert/repair BEFORE the merge wave lands. Step 11 is the final guard.
"""

import importlib
import inspect
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parent.parent.parent


def _import_trace():
    """Import installer.backend.trace fresh, with the verbose gate cleared so
    re-imports during the test don't share module-level state with prior runs."""
    # Ensure the repo root is on sys.path so `installer.backend.trace` resolves
    if str(REPO_ROOT) not in sys.path:
        sys.path.insert(0, str(REPO_ROOT))

    # Force re-import — the shared trace module caches sink state at import
    # time and we want the gate evaluated against the current env.
    for mod_name in (
        "_igos_trace_shared",
        "installer.backend.trace",
        "installer.backend._trace",
    ):
        sys.modules.pop(mod_name, None)
    return importlib.import_module("installer.backend.trace")


def test_forge_pre_lift_api_still_imports():
    """Every name Forge call sites used to import from installer.backend.trace
    must still resolve through the shim. Names captured from Forge's prior-art
    `__all__` (installer/backend/_trace.py:38-60 of the shim's explicit list)."""
    trace = _import_trace()
    pre_lift_names = (
        "is_verbose",
        "init_trace",
        "attach_target_sink",
        "close_trace",
        "traced_run",
        "traced_run_chroot",
        "traced_copy_file",
        "traced_write_file",
        "trace_event",
        "trace_install_step",
        "install_failure",
        "REDACT_KEYS",
    )
    for name in pre_lift_names:
        assert hasattr(trace, name), (
            f"Forge backward-compat break: installer.backend.trace lost '{name}'. "
            f"Step 11 regression — the lift's shim no longer re-exports it."
        )


def test_forge_debug_verbose_env_var_still_gates_verbose():
    """The forensic trace defaults ON pre-v1.0 (operator directive 2026-06-05,
    public 43257c2e) and FORGE_DEBUG_VERBOSE opts OUT (=0/false/no/off) or
    explicitly opts IN (=1). Updated from the pre-43257c2e default-OFF contract:
    a fresh install must always leave a forensic trail unless deliberately
    disabled, so this is what backward-compat now means."""
    saved_forge = os.environ.pop("FORGE_DEBUG_VERBOSE", None)
    saved_igos = os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
    try:
        # With NO env-var set, is_verbose() must be True (default-ON pre-v1.0).
        os.environ.pop("FORGE_DEBUG_VERBOSE", None)
        os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
        trace = _import_trace()
        assert trace.is_verbose() is True, (
            "is_verbose() False with no env-var — the pre-v1.0 default-ON "
            "forensic trace (43257c2e) regressed; installs would silently "
            "ship no trace."
        )

        # FORGE_DEBUG_VERBOSE=0 (and the other opt-out spellings) disables it.
        for off in ("0", "false", "no", "off"):
            os.environ["FORGE_DEBUG_VERBOSE"] = off
            trace = _import_trace()
            assert trace.is_verbose() is False, (
                f"FORGE_DEBUG_VERBOSE={off!r} did not opt out of verbose mode."
            )
        os.environ.pop("FORGE_DEBUG_VERBOSE", None)

        # With FORGE_DEBUG_VERBOSE=1, is_verbose() must be True.
        os.environ["FORGE_DEBUG_VERBOSE"] = "1"
        trace = _import_trace()
        assert trace.is_verbose() is True, (
            "FORGE_DEBUG_VERBOSE=1 did not enable verbose mode — Forge "
            "backward-compat broken."
        )
    finally:
        os.environ.pop("FORGE_DEBUG_VERBOSE", None)
        os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
        if saved_forge is not None:
            os.environ["FORGE_DEBUG_VERBOSE"] = saved_forge
        if saved_igos is not None:
            os.environ["IGOS_BUILD_DEBUG_VERBOSE"] = saved_igos
        if saved_forge is not None:
            os.environ["FORGE_DEBUG_VERBOSE"] = saved_forge
        if saved_igos is not None:
            os.environ["IGOS_BUILD_DEBUG_VERBOSE"] = saved_igos


def test_igos_build_debug_verbose_also_gates_verbose():
    """The canonical build-pipeline env-var also opts in to verbose mode."""
    saved_forge = os.environ.pop("FORGE_DEBUG_VERBOSE", None)
    saved_igos = os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
    try:
        os.environ["IGOS_BUILD_DEBUG_VERBOSE"] = "1"
        trace = _import_trace()
        assert trace.is_verbose() is True, (
            "IGOS_BUILD_DEBUG_VERBOSE=1 did not enable verbose mode — "
            "build-pipeline gate is broken."
        )
    finally:
        os.environ.pop("FORGE_DEBUG_VERBOSE", None)
        os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
        if saved_forge is not None:
            os.environ["FORGE_DEBUG_VERBOSE"] = saved_forge
        if saved_igos is not None:
            os.environ["IGOS_BUILD_DEBUG_VERBOSE"] = saved_igos


def test_traced_run_call_signature_preserved():
    """traced_run's signature must match Forge's prior-art shape:
    (cmd, *, input, env, cwd, check, timeout, phase, intent, ...)"""
    trace = _import_trace()
    sig = inspect.signature(trace.traced_run)
    params = sig.parameters
    # Forge's prior-art kwargs (preserved verbatim in the shared module):
    expected_keyword_only = ("input", "env", "cwd", "check", "timeout", "phase", "intent")
    for kw in expected_keyword_only:
        assert kw in params, (
            f"traced_run lost keyword-only parameter '{kw}'. Forge call "
            f"sites that pass this kwarg will break."
        )


def test_install_failure_signature_preserved():
    """install_failure's signature must match Forge's prior-art shape."""
    trace = _import_trace()
    sig = inspect.signature(trace.install_failure)
    params = sig.parameters
    expected = ("where", "why", "cmd", "rc", "stdout", "stderr", "extra")
    for name in expected:
        assert name in params, (
            f"install_failure lost keyword-only parameter '{name}'."
        )


def test_synthetic_forge_install_emits_jsonl():
    """Smoke: with FORGE_DEBUG_VERBOSE=1, a synthetic Forge-shaped sequence
    (init_trace -> traced_run -> trace_event -> close_trace) writes valid
    JSONL events to the /tmp sink. This is the operational property Forge
    install.py relies on — bit-identical to the pre-lift behavior."""
    saved_forge = os.environ.pop("FORGE_DEBUG_VERBOSE", None)
    saved_igos = os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
    tmpdir = tempfile.mkdtemp(prefix="forge-trace-smoke-")
    try:
        os.environ["FORGE_DEBUG_VERBOSE"] = "1"
        # We can't redirect Forge's hardcoded /tmp sink target in this test
        # without monkey-patching, so we use the alternate init_build_trace
        # which accepts trace_root=. Forge's actual flow uses init_trace +
        # /tmp; that path is exercised by Forge's own smoke tests + the
        # live-install harness. Here we assert the schema + structure.
        trace = _import_trace()

        # Re-load shared module with the new gate
        for mod_name in ("_igos_trace_shared",):
            sys.modules.pop(mod_name, None)
        trace = _import_trace()

        trace.init_build_trace(trace_root=Path(tmpdir))
        result = trace.traced_run(
            ["echo", "forge synthetic"],
            phase="forge_smoke",
            intent="smoke test traced_run after lift",
        )
        trace.trace_event(
            "forge_smoke_marker",
            note="this event must be emitted",
        )
        trace.close_trace()

        # Find the emitted file
        jsonl_files = list(Path(tmpdir).glob("*.jsonl"))
        assert jsonl_files, (
            "No JSONL emitted — FORGE_DEBUG_VERBOSE gate broken or "
            "init_build_trace failed silently."
        )

        # Each line must be valid JSON with type + ts
        lines_read = 0
        types_seen = set()
        for jsonl in jsonl_files:
            for line in jsonl.read_text().splitlines():
                if not line.strip():
                    continue
                lines_read += 1
                event = json.loads(line)
                assert "type" in event, f"event missing 'type': {event}"
                assert "ts" in event, f"event missing 'ts': {event}"
                types_seen.add(event["type"])

        # Must have seen at least the synthetic event types
        assert "trace_init" in types_seen, "trace_init event missing"
        assert "subprocess_start" in types_seen, "subprocess_start event missing"
        assert "subprocess_end" in types_seen, "subprocess_end event missing"
        assert "forge_smoke_marker" in types_seen, (
            "ad-hoc trace_event payload missing — trace_event broken"
        )

        # The echo subprocess must have rc=0
        for jsonl in jsonl_files:
            for line in jsonl.read_text().splitlines():
                if not line.strip():
                    continue
                event = json.loads(line)
                if event.get("type") == "subprocess_end":
                    assert event["rc"] == 0, (
                        f"echo subprocess returned non-zero: {event}"
                    )
                    assert event.get("stdout", "").strip() == "forge synthetic", (
                        "subprocess stdout not captured correctly — byte-level "
                        f"capture broken. Event: {event}"
                    )
    finally:
        # Cleanup
        for f in Path(tmpdir).glob("*"):
            try:
                f.unlink()
            except OSError:
                pass
        os.rmdir(tmpdir)
        os.environ.pop("FORGE_DEBUG_VERBOSE", None)
        os.environ.pop("IGOS_BUILD_DEBUG_VERBOSE", None)
        if saved_forge is not None:
            os.environ["FORGE_DEBUG_VERBOSE"] = saved_forge
        if saved_igos is not None:
            os.environ["IGOS_BUILD_DEBUG_VERBOSE"] = saved_igos


def test_redact_keys_constant_preserved():
    """REDACT_KEYS frozenset must still contain Forge's pre-lift entries."""
    trace = _import_trace()
    pre_lift_redact = {"password", "passphrase", "token", "secret"}
    for k in pre_lift_redact:
        assert k in trace.REDACT_KEYS, (
            f"REDACT_KEYS lost entry '{k}' after lift — PII/secret hygiene "
            f"regression."
        )


if __name__ == "__main__":
    # Allow running standalone: `python3 -m installer.tests.test_forge_trace_after_lift`
    import traceback
    fails = 0
    tests = [
        test_forge_pre_lift_api_still_imports,
        test_forge_debug_verbose_env_var_still_gates_verbose,
        test_igos_build_debug_verbose_also_gates_verbose,
        test_traced_run_call_signature_preserved,
        test_install_failure_signature_preserved,
        test_synthetic_forge_install_emits_jsonl,
        test_redact_keys_constant_preserved,
    ]
    for t in tests:
        try:
            t()
            print(f"  PASS: {t.__name__}")
        except Exception:
            print(f"  FAIL: {t.__name__}")
            traceback.print_exc()
            fails += 1
    if fails:
        print(f"\nForge regression: {fails}/{len(tests)} tests failed")
        sys.exit(1)
    print(f"\nForge regression: {len(tests)}/{len(tests)} tests passed")
    sys.exit(0)
