"""Exercise real child failures without publishing child output or altering its environment."""
import contextlib
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock

import android_build_diagnostics as diagnostics
import yaml


class BuildDiagnosticsTests(unittest.TestCase):
    def invoke(self, script, **kwargs):
        output = io.StringIO()
        with contextlib.redirect_stdout(output):
            code = diagnostics.run("flutter_bundle", [sys.executable, "-c", script], **kwargs)
        rows = [json.loads(line.split("::", 2)[2]) for line in output.getvalue().splitlines()]
        return code, rows, output.getvalue()

    def test_child_failure_keeps_exit_and_only_fixed_signals(self):
        code, rows, output = self.invoke(
            "import sys; print('secret=do-not-publish https://user:password@example.test');"
            "print('Missing classes detected while running R8'); sys.exit(42)"
        )
        self.assertEqual(code, 42)
        self.assertEqual(rows, [{"schema": "gowalk-cicd/android-build-failed.v1", "phase": "flutter_bundle",
                                 "exit_code": 42, "signals": ["r8_missing_classes"]}])
        self.assertNotIn("password", output)
        self.assertNotIn("do-not-publish", output)

    def test_unknown_failure_never_guesses_or_echoes_output(self):
        code, rows, output = self.invoke("import sys; print('private provider exception'); sys.exit(1)")
        self.assertEqual(code, 1)
        self.assertEqual(rows[0]["signals"], [])
        self.assertNotIn("private provider", output)

    def test_success_preserves_environment_and_arguments_without_echoing_them(self):
        with mock.patch.dict(os.environ, {"HTTPS_PROXY": "http://private.invalid:9", "NO_PROXY": ""}):
            code, rows, output = self.invoke(
                "import os; assert os.environ['HTTPS_PROXY']=='http://private.invalid:9';"
                "assert os.environ['NO_PROXY']==''; print('secret successful compiler output')"
            )
        self.assertEqual((code, rows, output), (0, [], ""))

    def test_large_output_stays_bounded_and_recent_failures_are_retained(self):
        tail = bytearray()
        content = b"secret" * diagnostics.LIMIT + b"Android resource linking failed"
        with io.BufferedReader(io.BytesIO(content)) as stream:
            diagnostics.drain(stream, tail)
        self.assertEqual(len(tail), diagnostics.LIMIT)
        self.assertEqual(diagnostics.signals(tail), ["android_resources"])

    def test_long_silent_build_emits_only_safe_progress(self):
        code, rows, _ = self.invoke("import time; time.sleep(.12)", interval=.03)
        self.assertEqual(code, 0)
        self.assertTrue(rows)
        for row in rows:
            self.assertEqual(row["schema"], "gowalk-cicd/android-build-progress.v1")
            self.assertEqual(row["phase"], "flutter_bundle")

    def test_missing_program_has_typed_failure_without_argv(self):
        output = io.StringIO()
        with contextlib.redirect_stdout(output):
            code = diagnostics.run("gradle_bundle", ["/missing/private-program"])
        self.assertEqual(code, 127)
        self.assertNotIn("private-program", output.getvalue())
        self.assertIn('"signals":["command_start_failed"]', output.getvalue())

    def test_cli_forwards_exact_argument_vector(self):
        with mock.patch.object(diagnostics, "run", return_value=7) as run:
            self.assertEqual(diagnostics.main(["--phase", "gradle_bundle", "--", "./gradlew", "a b"]), 7)
        run.assert_called_once_with("gradle_bundle", ["./gradlew", "a b"])

    def test_real_release_step_keeps_compiler_arguments_on_failure(self):
        root = Path(__file__).resolve().parents[1]
        action = yaml.safe_load((root / "action.yml").read_text())
        step = next(row for row in action["runs"]["steps"] if row["name"] == "Build release Android App Bundle")
        with tempfile.TemporaryDirectory() as directory:
            cwd = Path(directory)
            flutter = cwd / "flutter"
            flutter.write_text(f"#!{sys.executable}\nimport sys,json\nfrom pathlib import Path\n"
                               "Path('arguments.json').write_text(json.dumps(sys.argv[1:]))\n"
                               "print('Missing classes detected while running R8: private-class')\nsys.exit(41)\n")
            flutter.chmod(0o700)
            env = {**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"],
                   "ANDROID_BUILD_NUMBER": "17", "BUILD_NAME": "", "DART_DEFINES": "",
                   "DART_SYMBOLS_DIR": str(cwd / "symbols")}
            script = step["run"].replace("${{ github.action_path }}", str(root))
            result = subprocess.run(["bash", "-eo", "pipefail", "-c", script], cwd=cwd, env=env,
                                    capture_output=True, text=True, timeout=20)
            args = json.loads((cwd / "arguments.json").read_text())
        self.assertEqual(result.returncode, 41, result.stdout + result.stderr)
        self.assertIn("--obfuscate", args)
        self.assertIn("--split-debug-info", args)
        self.assertIn('"signals":["r8_missing_classes"]', result.stdout)
        self.assertNotIn("private-class", result.stdout + result.stderr)

    def test_dependency_failure_names_hosts_and_artifacts_without_credentials(self):
        """A signal alone cannot be acted on; the record must LOCATE the failure.

        Task 1183's run 34362765661 recorded only the two signal names for a
        15-minute build, so the session could only run it again.
        """
        code, rows, output = self.invoke(
            "import sys; print(\"Could not resolve all files for configuration ':app:releaseRuntimeClasspath'\");"
            "print('Could not resolve com.google.firebase:firebase-analytics:21.5.0');"
            "print(\"Could not GET 'https://proxyuser:sup3rSecret@residential.exit.test:8080/maven2/x.pom'\");"
            "print('PKIX path building failed: unable to find valid certification path to dl.google.com');"
            "sys.exit(1)"
        )
        self.assertEqual(code, 1)
        row = rows[0]
        self.assertEqual(row["signals"], ["dependency_resolution", "tls_verification"])
        self.assertIn("dl.google.com", row["hosts"])
        self.assertIn("residential.exit.test", row["hosts"])
        self.assertIn("com.google.firebase:firebase-analytics:21.5.0", row["artifacts"])
        for secret in ("sup3rSecret", "proxyuser", "maven2", "x.pom"):
            self.assertNotIn(secret, output)

    def test_extracted_detail_is_bounded_and_ordered(self):
        text = "".join(f"Could not GET 'https://host{n}.test/a'\n" for n in range(40)).encode()
        found = ["dependency_resolution"]
        hosts = diagnostics.details(text, found)["hosts"]
        self.assertEqual(len(hosts), diagnostics.MAX_DETAILS)
        self.assertEqual(hosts, sorted(hosts))

    def test_a_signal_that_needs_no_locating_adds_no_detail(self):
        code, rows, _ = self.invoke(
            "import sys; print('Missing classes detected while running R8'); sys.exit(3)")
        self.assertEqual(code, 3)
        self.assertEqual(set(rows[0]), {"schema", "phase", "exit_code", "signals"})

    def test_userinfo_never_reaches_the_record_even_without_a_signal_match(self):
        detail = diagnostics.details(b"Could not GET 'https://u:p@only.test/x'", ["dependency_resolution"])
        self.assertEqual(detail["hosts"], ["only.test"])
        self.assertNotIn("p@", json.dumps(detail))


if __name__ == "__main__":
    unittest.main()
