#!/usr/bin/env python3
"""
Tests for app_context_scanner.py.

Uses tmp_path-style temp dirs to build a synthetic repo and asserts:
  * total output stays <=10KB,
  * sections appear in deterministic order across runs,
  * missing files degrade gracefully to "<unavailable>",
  * path-traversal / symlink escapes are rejected.
"""

from __future__ import annotations

import os
import plistlib
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).resolve().parent))

import app_context_scanner as scanner  # noqa: E402


def _write_plist(path: Path, data: dict) -> None:
    with open(path, "wb") as f:
        plistlib.dump(data, f)


def _build_sample_repo(root: Path, scheme: str = "SampleApp") -> None:
    (root / "README.md").write_text("# Sample App\n\nA sample VPN client.")
    scheme_dir = root / scheme
    scheme_dir.mkdir()
    _write_plist(scheme_dir / "Info.plist", {
        "CFBundleDisplayName": "Sample",
        "CFBundleShortVersionString": "1.0.0",
        "NSLocationWhenInUseUsageDescription": "Need location",
    })
    (root / "Package.swift").write_text(
        "// swift-tools-version:5.9\n"
        ".package(url: \"https://github.com/firebase/firebase-ios-sdk\", from: \"10.0.0\"),\n"
        ".package(url: \"https://github.com/example/other\", from: \"1.0.0\"),\n"
    )
    (scheme_dir / "ContentView.swift").write_text(
        "import SwiftUI\nstruct ContentView: View { var body: some View { Text(\"Hi\") } }\n"
    )
    (scheme_dir / "VPNManager.swift").write_text(
        "import NetworkExtension\nclass VPNManager { }\n" * 4
    )
    ent = {"com.apple.developer.networking.vpn.api": ["allow-vpn"]}
    _write_plist(root / "Sample.entitlements", ent)


class BuildBlobTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.root = Path(self.tmp.name).resolve()
        _build_sample_repo(self.root)

    def _run(self) -> str:
        env = {
            "GITHUB_WORKSPACE": str(self.root),
            "BUNDLE_ID": "com.example.sample",
            "SCHEME": "SampleApp",
        }
        with mock.patch.dict(os.environ, env, clear=True):
            return scanner.build_blob()

    def test_blob_has_all_sections_in_order(self):
        blob = self._run()
        ordered = [
            "===APP CONTEXT===",
            "===README===",
            "===INFO_PLIST===",
            "===DEPENDENCIES===",
            "===SWIFT_FILES===",
            "===ENTITLEMENTS===",
            "===END===",
        ]
        positions = [blob.find(marker) for marker in ordered]
        self.assertTrue(all(p >= 0 for p in positions), blob)
        self.assertEqual(positions, sorted(positions))

    def test_blob_under_10kb(self):
        blob = self._run()
        self.assertLess(len(blob.encode("utf-8")), 10_000)

    def test_blob_is_deterministic_across_runs(self):
        first = self._run()
        runs = [self._run() for _ in range(4)]
        for r in runs:
            self.assertEqual(r, first)

    def test_blob_contains_bundle_id_and_scheme(self):
        blob = self._run()
        self.assertIn("BUNDLE_ID: com.example.sample", blob)
        self.assertIn("SCHEME: SampleApp", blob)

    def test_missing_readme_renders_unavailable(self):
        (self.root / "README.md").unlink()
        blob = self._run()
        # section header must still be present, with <unavailable>
        readme_idx = blob.find("===README===")
        info_idx = blob.find("===INFO_PLIST===")
        section = blob[readme_idx:info_idx]
        self.assertIn("<unavailable>", section)

    def test_dependencies_lists_package_entries(self):
        blob = self._run()
        self.assertIn("firebase-ios-sdk", blob)

    def test_entitlements_keys_present(self):
        blob = self._run()
        self.assertIn("com.apple.developer.networking.vpn.api", blob)

    def test_info_plist_permissions_extracted(self):
        blob = self._run()
        self.assertIn("NSLocationWhenInUseUsageDescription", blob)


class SafePathTests(unittest.TestCase):
    def test_safe_path_rejects_outside_root(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp).resolve()
            other = Path(tempfile.mkdtemp())
            try:
                (other / "leak.txt").write_text("secret")
                got = scanner._safe_path(root, other / "leak.txt")
                self.assertIsNone(got)
            finally:
                for p in other.iterdir():
                    p.unlink()
                other.rmdir()

    def test_safe_path_accepts_inside_root(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp).resolve()
            target = root / "inside.txt"
            target.write_text("ok")
            got = scanner._safe_path(root, target)
            self.assertEqual(got, target.resolve())

    def test_safe_path_returns_none_for_missing(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp).resolve()
            got = scanner._safe_path(root, root / "nope.txt")
            self.assertIsNone(got)


class FailOpenTests(unittest.TestCase):
    def test_main_fails_open_on_exception(self):
        import io
        stdout = io.StringIO()
        with mock.patch.object(scanner, "build_blob", side_effect=RuntimeError("boom")), \
             mock.patch.object(sys, "stdout", stdout):
            rc = scanner.main()
        self.assertEqual(rc, 0)
        self.assertIn("===APP CONTEXT===", stdout.getvalue())
        self.assertIn("<unavailable>", stdout.getvalue())
        self.assertIn("===END===", stdout.getvalue())


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