"""Tests for scanner modules and rule engine."""

from __future__ import annotations

import json
import plistlib
import tempfile
from pathlib import Path

import pytest

from oncecheck.engine.runner import Finding, ScanResult, run_scan
from oncecheck.engine.reporter import export_json, export_text, export_sarif
from oncecheck.scanners.detector import Platform, DetectionResult, detect_platforms


# ---------------------------------------------------------------------------
# Fixtures — create minimal project structures in temp dirs
# ---------------------------------------------------------------------------

@pytest.fixture
def ios_project(tmp_path):
    """Create a minimal iOS project that triggers known rules."""
    # Info.plist with ATS disabled, no camera key
    plist_data = {
        "CFBundleIdentifier": "com.example.testapp",
        "NSAppTransportSecurity": {"NSAllowsArbitraryLoads": True},
    }
    plist_path = tmp_path / "Info.plist"
    with open(plist_path, "wb") as f:
        plistlib.dump(plist_data, f)

    # Swift source using camera API
    src_dir = tmp_path / "Sources"
    src_dir.mkdir()
    (src_dir / "Camera.swift").write_text(
        'import AVFoundation\nlet session = AVCaptureSession()\n'
    )
    # HTTP URL in source
    (src_dir / "API.swift").write_text(
        'let url = "http://api.example.com/data"\n'
    )
    return tmp_path


@pytest.fixture
def ios_project_with_secrets(tmp_path):
    """iOS project with hardcoded secrets."""
    plist_data = {"CFBundleIdentifier": "com.example.testapp"}
    with open(tmp_path / "Info.plist", "wb") as f:
        plistlib.dump(plist_data, f)
    src_dir = tmp_path / "Sources"
    src_dir.mkdir()
    (src_dir / "Config.swift").write_text(
        'let apiKey = "sk_live_abcdefghijklmnopqrstuvwxyz1234"\n'
        'let session = URLSession.shared\n'
    )
    return tmp_path


@pytest.fixture
def android_project(tmp_path):
    """Create a minimal Android project that triggers known rules."""
    # build.gradle with low target SDK and compileSdk < targetSdk
    (tmp_path / "build.gradle").write_text(
        'android {\n'
        '    compileSdkVersion 30\n'
        '    defaultConfig {\n'
        '        targetSdkVersion 33\n'
        '    }\n'
        '}\n'
    )
    # AndroidManifest.xml with dangerous permissions and debuggable
    (tmp_path / "AndroidManifest.xml").write_text(
        '<?xml version="1.0" encoding="utf-8"?>\n'
        '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
        '    <uses-permission android:name="android.permission.CAMERA" />\n'
        '    <uses-permission android:name="android.permission.READ_SMS" />\n'
        '    <application android:debuggable="true">\n'
        '    </application>\n'
        '</manifest>\n'
    )
    return tmp_path


@pytest.fixture
def android_project_with_secrets(tmp_path):
    """Android project with hardcoded secrets."""
    (tmp_path / "build.gradle").write_text(
        'android { compileSdkVersion 34\n'
        '    defaultConfig { targetSdkVersion 34 }\n'
        '}\n'
    )
    (tmp_path / "AndroidManifest.xml").write_text(
        '<?xml version="1.0" encoding="utf-8"?>\n'
        '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
        '    <application></application>\n'
        '</manifest>\n'
    )
    src_dir = tmp_path / "java"
    src_dir.mkdir()
    (src_dir / "Config.java").write_text(
        'String apiKey = "AIzaSyAbcdefghijklmnopqrstuvwxyz123456";\n'
    )
    return tmp_path


@pytest.fixture
def web_project(tmp_path):
    """Create a minimal web project that triggers known rules."""
    # package.json + lockfile
    (tmp_path / "package.json").write_text('{"name": "test", "version": "1.0.0"}')
    (tmp_path / "package-lock.json").write_text("{}")

    # next.config.js (empty — no security headers)
    (tmp_path / "next.config.js").write_text("module.exports = {};")

    # Source with issues
    src_dir = tmp_path / "src"
    src_dir.mkdir()
    (src_dir / "page.tsx").write_text(
        '<div onClick={() => {}}>\n'
        '  <img src="photo.jpg" />\n'
        '</div>\n'
        '<script>eval("code")</script>\n'
        '<script src="https://www.googletagmanager.com/gtag.js"></script>\n'
    )
    return tmp_path


@pytest.fixture
def web_project_with_xss(tmp_path):
    """Web project with innerHTML/dangerouslySetInnerHTML usage."""
    (tmp_path / "package.json").write_text('{"name": "test", "version": "1.0.0"}')
    (tmp_path / "next.config.js").write_text("module.exports = {};")
    src_dir = tmp_path / "src"
    src_dir.mkdir()
    (src_dir / "page.tsx").write_text(
        'element.innerHTML = userInput;\n'
        '<div dangerouslySetInnerHTML={{__html: content}} />\n'
    )
    return tmp_path


@pytest.fixture
def clean_web_project(tmp_path):
    """A web project with no compliance issues."""
    (tmp_path / "package.json").write_text('{"name": "clean", "version": "1.0.0"}')
    (tmp_path / "next.config.js").write_text(
        'module.exports = { headers: () => [{ '
        'key: "Content-Security-Policy", value: "default-src self", '
        'key: "Strict-Transport-Security", value: "max-age=31536000", '
        'key: "X-Frame-Options", value: "DENY", '
        'key: "X-Content-Type-Options", value: "nosniff", '
        'key: "Referrer-Policy", value: "strict-origin", '
        'key: "Permissions-Policy", value: "camera=()" '
        '}] };'
    )
    pages_dir = tmp_path / "pages"
    pages_dir.mkdir()
    (pages_dir / "privacy-policy.tsx").write_text("<div>Privacy Policy</div>")
    (pages_dir / "terms.tsx").write_text("<div>Terms of Service</div>")
    (pages_dir / "index.tsx").write_text(
        '<button onClick={() => {}}>Click</button>\n'
        '<img alt="photo" src="photo.jpg" />\n'
    )
    return tmp_path


# ---------------------------------------------------------------------------
# Detector tests
# ---------------------------------------------------------------------------

class TestDetector:
    def test_detect_ios(self, ios_project):
        results = detect_platforms(ios_project)
        platforms = [r.platform for r in results]
        assert Platform.IOS in platforms

    def test_detect_android(self, android_project):
        results = detect_platforms(android_project)
        platforms = [r.platform for r in results]
        assert Platform.ANDROID in platforms

    def test_detect_web(self, web_project):
        results = detect_platforms(web_project)
        platforms = [r.platform for r in results]
        assert Platform.WEB in platforms

    def test_detect_empty_dir(self, tmp_path):
        results = detect_platforms(tmp_path)
        assert results == []


# ---------------------------------------------------------------------------
# iOS scanner tests
# ---------------------------------------------------------------------------

class TestIOSScanner:
    def test_catches_missing_camera_desc(self, ios_project):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project)
        ids = [f.rule_id for f in findings]
        assert "IOS-PRIV-001" in ids

    def test_catches_ats_disabled(self, ios_project):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-001" in ids

    def test_catches_http_urls(self, ios_project):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-002" in ids

    def test_catches_missing_privacy_manifest(self, ios_project):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project)
        ids = [f.rule_id for f in findings]
        assert "IOS-PRIV-007" in ids

    def test_catches_hardcoded_secrets(self, ios_project_with_secrets):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project_with_secrets)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-003" in ids

    def test_catches_missing_ssl_pinning(self, ios_project_with_secrets):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project_with_secrets)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-004" in ids

    def test_catches_keychain_usage(self, tmp_path):
        """Detects sensitive data in UserDefaults instead of Keychain."""
        plist_data = {"CFBundleIdentifier": "com.example.testapp"}
        with open(tmp_path / "Info.plist", "wb") as f:
            plistlib.dump(plist_data, f)
        src_dir = tmp_path / "Sources"
        src_dir.mkdir()
        (src_dir / "Auth.swift").write_text(
            'UserDefaults.standard.set(token, forKey: "password")\n'
        )
        from oncecheck.scanners.ios import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-005" in ids

    def test_ignores_example_secret_markers(self, tmp_path):
        plist_data = {"CFBundleIdentifier": "com.example.testapp"}
        with open(tmp_path / "Info.plist", "wb") as f:
            plistlib.dump(plist_data, f)
        src_dir = tmp_path / "Sources"
        src_dir.mkdir()
        (src_dir / "Example.swift").write_text(
            '// oncecheck:allow-secret\\n'
            'let token = "sk_live_example_only_secret_token_123456789"\\n'
        )
        from oncecheck.scanners.ios import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-003" not in ids

    def test_interprocedural_insecure_storage_flow(self, tmp_path):
        plist_data = {"CFBundleIdentifier": "com.example.testapp"}
        with open(tmp_path / "Info.plist", "wb") as f:
            plistlib.dump(plist_data, f)
        src_dir = tmp_path / "Sources"
        src_dir.mkdir()
        (src_dir / "Storage.swift").write_text(
            "func storeSensitive(value: String) { UserDefaults.standard.set(value, forKey: \"token\") }\\n"
        )
        (src_dir / "Auth.swift").write_text(
            "let accessToken = sessionToken\\n"
            "storeSensitive(value: accessToken)\\n"
        )
        from oncecheck.scanners.ios import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-005" in ids

    def test_interprocedural_secret_flow(self, tmp_path):
        plist_data = {"CFBundleIdentifier": "com.example.testapp"}
        with open(tmp_path / "Info.plist", "wb") as f:
            plistlib.dump(plist_data, f)
        src_dir = tmp_path / "Sources"
        src_dir.mkdir()
        (src_dir / "Secrets.swift").write_text(
            "func wireToken(value: String) { let payload = value }\\n"
        )
        (src_dir / "Auth.swift").write_text(
            'let apiToken = \"sk_live_abcdefghijklmnopqrstuvwxyz1234\"\\n'
            "wireToken(value: apiToken)\\n"
        )
        from oncecheck.scanners.ios import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "IOS-SEC-003" in ids

    def test_catches_healthkit_missing_desc(self, tmp_path):
        """Detects HealthKit usage without NSHealthShareUsageDescription."""
        plist_data = {"CFBundleIdentifier": "com.example.testapp"}
        with open(tmp_path / "Info.plist", "wb") as f:
            plistlib.dump(plist_data, f)
        src_dir = tmp_path / "Sources"
        src_dir.mkdir()
        (src_dir / "Health.swift").write_text(
            'let store = HKHealthStore()\n'
        )
        from oncecheck.scanners.ios import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "IOS-PRIV-009" in ids

    def test_all_findings_have_fix(self, ios_project):
        from oncecheck.scanners.ios import scan
        findings = scan(ios_project)
        for f in findings:
            assert f.fix, f"Finding {f.rule_id} has no fix"


# ---------------------------------------------------------------------------
# Android scanner tests
# ---------------------------------------------------------------------------

class TestAndroidScanner:
    def test_catches_low_target_sdk(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        ids = [f.rule_id for f in findings]
        assert "AND-SDK-001" in ids

    def test_catches_compile_sdk_mismatch(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        ids = [f.rule_id for f in findings]
        assert "AND-SDK-002" in ids

    def test_catches_dangerous_permissions(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        ids = [f.rule_id for f in findings]
        assert "AND-PERM-001" in ids

    def test_catches_sms_permission(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        ids = [f.rule_id for f in findings]
        assert "AND-PERM-003" in ids

    def test_catches_debuggable(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        ids = [f.rule_id for f in findings]
        assert "AND-SEC-001" in ids

    def test_catches_hardcoded_secrets(self, android_project_with_secrets):
        from oncecheck.scanners.android import scan
        findings = scan(android_project_with_secrets)
        ids = [f.rule_id for f in findings]
        assert "AND-DATA-002" in ids

    def test_ignores_example_secret_markers(self, tmp_path):
        (tmp_path / "build.gradle").write_text(
            'android { compileSdkVersion 34\\n'
            '    defaultConfig { targetSdkVersion 34 }\\n'
            '}\\n'
            '// oncecheck:allow-secret\\n'
            'String apiKey = \"AIzaSyExampleOnlyToken1234567890123456\"\\n'
        )
        (tmp_path / "AndroidManifest.xml").write_text(
            '<?xml version=\"1.0\" encoding=\"utf-8\"?>\\n'
            '<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\\n'
            '    <application></application>\\n'
            '</manifest>\\n'
        )
        from oncecheck.scanners.android import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "AND-DATA-002" not in ids

    def test_interprocedural_secret_flow(self, tmp_path):
        (tmp_path / "build.gradle").write_text(
            'android { compileSdkVersion 34\\n'
            '    defaultConfig { targetSdkVersion 34 }\\n'
            '}\\n'
        )
        (tmp_path / "AndroidManifest.xml").write_text(
            '<?xml version=\"1.0\" encoding=\"utf-8\"?>\\n'
            '<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\\n'
            '    <application></application>\\n'
            '</manifest>\\n'
        )
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "Secrets.kt").write_text(
            'fun putApiKey(value: String) {\\n'
            '  prefs.putString(\"api_key\", value)\\n'
            '}\\n'
        )
        (src_dir / "Use.kt").write_text(
            'val apiKey = \"AIzaSyAbcdefghijklmnopqrstuvwxyz123456\"\\n'
            'putApiKey(apiKey)\\n'
        )
        from oncecheck.scanners.android import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "AND-DATA-002" in ids

    def test_catches_missing_proguard(self, tmp_path):
        """Detects missing ProGuard/R8 obfuscation in release build."""
        (tmp_path / "build.gradle").write_text(
            'android {\n'
            '    compileSdkVersion 34\n'
            '    defaultConfig { targetSdkVersion 34 }\n'
            '    buildTypes {\n'
            '        release {\n'
            '            minifyEnabled false\n'
            '        }\n'
            '    }\n'
            '}\n'
        )
        (tmp_path / "AndroidManifest.xml").write_text(
            '<?xml version="1.0" encoding="utf-8"?>\n'
            '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
            '    <application></application>\n'
            '</manifest>\n'
        )
        from oncecheck.scanners.android import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "AND-SEC-005" in ids

    def test_catches_fg_service_no_type(self, tmp_path):
        """Detects foreground service without foregroundServiceType."""
        (tmp_path / "build.gradle").write_text(
            'android { compileSdkVersion 34\n'
            '    defaultConfig { targetSdkVersion 34 }\n'
            '}\n'
        )
        (tmp_path / "AndroidManifest.xml").write_text(
            '<?xml version="1.0" encoding="utf-8"?>\n'
            '<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
            '    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />\n'
            '    <application>\n'
            '        <service android:name=".MyService" />\n'
            '    </application>\n'
            '</manifest>\n'
        )
        from oncecheck.scanners.android import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "AND-POL-003" in ids

    def test_all_findings_have_fix(self, android_project):
        from oncecheck.scanners.android import scan
        findings = scan(android_project)
        for f in findings:
            assert f.fix, f"Finding {f.rule_id} has no fix"


# ---------------------------------------------------------------------------
# Web scanner tests
# ---------------------------------------------------------------------------

class TestWebScanner:
    def test_catches_missing_csp(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-SEC-001" in ids

    def test_catches_eval_usage(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-002" in ids

    def test_catches_img_no_alt(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-A11Y-001" in ids

    def test_catches_div_onclick(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-A11Y-002" in ids

    def test_catches_tracking_no_consent(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-PRIV-001" in ids

    def test_catches_missing_privacy_policy(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-PRIV-002" in ids

    def test_catches_missing_x_content_type(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-SEC-004" in ids

    def test_catches_innerHTML_xss(self, web_project_with_xss):
        from oncecheck.scanners.web import scan
        findings = scan(web_project_with_xss)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-003" in ids

    def test_catches_missing_sri(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-005" in ids

    def test_catches_cors_wildcard(self, tmp_path):
        """Detects CORS wildcard origin misconfiguration."""
        (tmp_path / "package.json").write_text('{"name": "test", "version": "1.0.0"}')
        (tmp_path / "server.js").write_text(
            'app.use(cors());\n'
            'res.header("Access-Control-Allow-Origin", "*");\n'
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-SEC-008" in ids

    def test_catches_missing_rate_limiting(self, web_project):
        from oncecheck.scanners.web import scan
        findings = scan(web_project)
        ids = [f.rule_id for f in findings]
        assert "WEB-SEC-010" in ids

    def test_does_not_flag_sanitized_innerhtml(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"safe-web","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "safe.tsx").write_text(
            "import DOMPurify from 'dompurify';\\n"
            "const safe = DOMPurify.sanitize(userInput);\\n"
            "element.innerHTML = safe;\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-003" not in ids

    def test_does_not_flag_samesite_none_with_secure(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"safe-cookie","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "cookie.ts").write_text(
            "setCookie('sid', token, { sameSite: 'none', secure: true, httpOnly: true });\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-004" not in ids

    def test_interprocedural_xss_detection(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"flow-web","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "flow.ts").write_text(
            "function renderUnsafe(content) { element.innerHTML = content; }\\n"
            "const input = req.query.name;\\n"
            "renderUnsafe(input);\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        xss = [f for f in findings if f.rule_id == "WEB-OWASP-003"]
        assert xss
        assert xss[0].file_path
        assert xss[0].line > 0

    def test_cross_file_interprocedural_xss_detection(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"flow-web-2","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "utils.ts").write_text(
            "export function renderUnsafe(content) { element.innerHTML = content; }\\n"
        )
        (src_dir / "page.ts").write_text(
            "import { renderUnsafe } from './utils';\\n"
            "const input = req.query.name;\\n"
            "renderUnsafe(input);\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-003" in ids

    def test_does_not_flag_static_innerhtml_variable(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"safe-static","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "safe.ts").write_text(
            "const html = '<p>Hello</p>';\\n"
            "element.innerHTML = html;\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-003" not in ids

    def test_does_not_flag_allowlisted_redirect(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"safe-redirect","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "redirect.ts").write_text(
            "const next = req.query.next;\\n"
            "if (next && String(next).startsWith('/')) {\\n"
            "  res.redirect(next);\\n"
            "}\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "WEB-OWASP-006" not in ids

    def test_catches_weak_hash_algorithms(self, tmp_path):
        (tmp_path / "package.json").write_text('{"name":"weak-hash","version":"1.0.0"}')
        (tmp_path / "next.config.js").write_text("module.exports = {};")
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "crypto.ts").write_text(
            "import crypto from 'crypto';\\n"
            "const digest = crypto.createHash('md5').update(userInput).digest('hex');\\n"
        )
        from oncecheck.scanners.web import scan
        findings = scan(tmp_path)
        weak = [f for f in findings if f.rule_id == "WEB-OWASP-008"]
        assert weak
        assert weak[0].file_path
        assert weak[0].line > 0


# ---------------------------------------------------------------------------
# Common (cross-platform) scanner tests
# ---------------------------------------------------------------------------

class TestCommonScanner:
    def test_catches_deprecated_apis(self, tmp_path):
        """Detects deprecated API usage."""
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "App.jsx").write_text(
            'class MyComponent extends React.Component {\n'
            '    componentWillMount() { console.log("mounting"); }\n'
            '}\n'
        )
        from oncecheck.scanners.common import scan
        findings = scan(tmp_path)
        ids = [f.rule_id for f in findings]
        assert "SUPPLY-API-001" in ids

    def test_all_findings_have_fix(self, tmp_path):
        """All common scanner findings have a fix."""
        src_dir = tmp_path / "src"
        src_dir.mkdir()
        (src_dir / "App.jsx").write_text('const x = 1;\n')
        from oncecheck.scanners.common import scan
        findings = scan(tmp_path)
        for f in findings:
            assert f.fix, f"Finding {f.rule_id} has no fix"


# ---------------------------------------------------------------------------
# Rule engine tests
# ---------------------------------------------------------------------------

class TestRuleEngine:
    def test_run_scan_ios(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        assert isinstance(result, ScanResult)
        assert result.platform == Platform.IOS
        assert result.scan_time_seconds >= 0
        assert result.fail_count > 0

    def test_run_scan_android(self, android_project):
        detection = DetectionResult(Platform.ANDROID, android_project, "Gradle", 0.9)
        result = run_scan(detection)
        assert isinstance(result, ScanResult)
        assert result.fail_count > 0

    def test_scan_result_to_dict(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        if result.findings:
            result.findings[0].file_path = "src/example.swift"
            result.findings[0].line = 12
        d = result.to_dict()
        assert d["platform"] == "ios"
        assert "findings" in d
        assert len(d["findings"]) > 0
        assert "file_path" in d["findings"][0]
        assert "line" in d["findings"][0]

    def test_run_scan_rejects_invalid_analysis_mode(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        with pytest.raises(ValueError, match="Invalid analysis_mode"):
            run_scan(detection, analysis_mode="bad-mode")


# ---------------------------------------------------------------------------
# Reporter tests
# ---------------------------------------------------------------------------

class TestReporter:
    def test_export_json(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        text = export_json([result])
        data = json.loads(text)
        assert "scans" in data
        assert len(data["scans"]) == 1

    def test_export_text(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        if result.findings:
            result.findings[0].file_path = "src/example.swift"
            result.findings[0].line = 12
        text = export_text([result])
        assert "FAIL" in text or "WARN" in text
        assert "At: src/example.swift:12" in text

    def test_export_sarif(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        if result.findings:
            result.findings[0].file_path = "src/example.swift"
            result.findings[0].line = 12
        text = export_sarif([result])
        data = json.loads(text)
        assert data["version"] == "2.1.0"
        assert len(data["runs"]) == 1
        assert "locations" in data["runs"][0]["results"][0]

    def test_export_json_to_file(self, ios_project, tmp_path):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        out = tmp_path / "results.json"
        export_json([result], out)
        assert out.exists()
        data = json.loads(out.read_text())
        assert "scans" in data

    def test_export_path_validation(self, ios_project):
        detection = DetectionResult(Platform.IOS, ios_project, "SwiftUI", 0.9)
        result = run_scan(detection)
        with pytest.raises(ValueError):
            export_json([result], "/etc/passwd")


# ---------------------------------------------------------------------------
# Token store tests
# ---------------------------------------------------------------------------

class TestTokenStore:
    def test_save_and_load(self, tmp_path, monkeypatch):
        from oncecheck.auth import token_store
        monkeypatch.setattr(token_store, "_token_dir", lambda: tmp_path)
        token_store.save_token({"access_token": "test123", "plan": "pro"})
        loaded = token_store.load_token()
        assert loaded is not None
        assert loaded["access_token"] == "test123"
        assert "issued_at" in loaded

    def test_expired_token(self, tmp_path, monkeypatch):
        import time
        from oncecheck.auth import token_store
        monkeypatch.setattr(token_store, "_token_dir", lambda: tmp_path)
        token_store.save_token({"access_token": "old", "issued_at": time.time() - 999999})
        loaded = token_store.load_token()
        assert loaded is None

    def test_clear_token(self, tmp_path, monkeypatch):
        from oncecheck.auth import token_store
        monkeypatch.setattr(token_store, "_token_dir", lambda: tmp_path)
        token_store.save_token({"access_token": "test"})
        token_store.clear_token()
        assert token_store.load_token() is None
