"""The installer bundle lock: fail-closed behaviour, and parity between the two
implementations that read it.

There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
the Linux builder without adding pwsh as a build dependency. Two implementations
of one rule drift. These tests run BOTH against the same fixtures and fail if
they disagree, so the drift shows up here rather than as a bundle that one of
them waves through.

The PowerShell half is skipped where pwsh is absent; the Python half always runs.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path

import pytest

INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
PWSH = shutil.which('pwsh') or shutil.which('powershell')

pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')


def build_bundle(root):
    """A minimal bundle holding every REQUIRED payload directory."""
    for name, files in (
        ('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
                    'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
        ('python', {'python-3.14.6-amd64.exe': b'py'}),
        ('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
    ):
        directory = root / name
        directory.mkdir(parents=True)
        for filename, content in files.items():
            (directory / filename).write_bytes(content)
    return root


def write_lock(bundle, lock_path):
    """Generate the lock the same way refresh-bundle-lock.ps1 does."""
    sys.path.insert(0, str(INSTALLER))
    import verify_bundle_lock as verifier

    payloads = {}
    for name, required, _what in verifier.PAYLOADS:
        directory = bundle / name
        if not directory.is_dir():
            continue
        payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
    lock_path.write_text(json.dumps({
        'schema': 1, 'generated': '2026-08-03T00:00:00Z',
        'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
    }))


def check_python(bundle, lock_path):
    result = subprocess.run(
        [sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
        capture_output=True, text=True)
    problems = [line for line in result.stdout.splitlines() if line.strip()]
    # Exit status and output must agree, or a caller that checks only one of them
    # gets a different answer from a caller that checks the other.
    assert (result.returncode != 0) == bool(problems), result.stdout
    return problems


def check_powershell(bundle, lock_path):
    script = (
        ". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
        "if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
    result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
                            capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    return [line for line in result.stdout.splitlines() if line.strip()]


def check_both(bundle, lock_path):
    """Returns the Python verdict, asserting PowerShell reports the same set.

    Compared as sets: both sort their output, but PowerShell's Sort-Object is
    culture-aware and Python's sorted() is ordinal, so the two can order the same
    findings differently. What must never differ is WHICH problems are found.
    """
    problems = check_python(bundle, lock_path)
    if PWSH and LOCK_PS1.exists():
        assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
            'verify_bundle_lock.py and bundle-lock.ps1 disagree')
    return problems


@pytest.fixture
def locked(tmp_path):
    bundle = build_bundle(tmp_path / 'bundle')
    lock = tmp_path / 'bundle-lock.json'
    write_lock(bundle, lock)
    return bundle, lock


def test_untouched_bundle_passes(locked):
    bundle, lock = locked
    assert check_both(bundle, lock) == []


def test_extra_file_fails(locked):
    """The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
    bundle, lock = locked
    (bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
    problems = check_both(bundle, lock)
    assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)


def test_altered_file_fails(locked):
    bundle, lock = locked
    (bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
    problems = check_both(bundle, lock)
    assert any('does NOT match the lock' in p for p in problems)


def test_missing_file_fails(locked):
    bundle, lock = locked
    (bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
    problems = check_both(bundle, lock)
    assert any('missing from the bundle' in p for p in problems)


def test_missing_required_payload_fails(locked):
    bundle, lock = locked
    shutil.rmtree(bundle / 'python')
    problems = check_both(bundle, lock)
    assert any(p.startswith('python/') for p in problems)


def test_unlocked_payload_directory_fails(locked):
    """An optional payload dropped in after the lock was made is still unreviewed."""
    bundle, lock = locked
    (bundle / 'mysql').mkdir()
    (bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
    problems = check_both(bundle, lock)
    assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)


def test_absent_optional_payload_is_fine(locked):
    bundle, lock = locked
    assert not (bundle / 'mysql').exists()
    assert check_both(bundle, lock) == []


def test_missing_lock_fails_closed(locked):
    """No lock is a failure, not a pass. The build must not fall through to 'fine'."""
    bundle, lock = locked
    lock.unlink()
    result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
                            capture_output=True, text=True)
    assert result.returncode != 0
    assert 'no bundle-lock.json' in result.stdout


def test_malformed_lock_reports_rather_than_crashing(locked):
    """A truncated lock must produce a sentence, not a stack trace.

    shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
    that is not there throws. Both verifiers have to survive a lock missing the
    section they read first.
    """
    bundle, lock = locked
    lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
    problems = check_both(bundle, lock)
    assert problems == ['bundle-lock.json has no "payloads" section']


def test_lock_missing_files_section_is_not_a_silent_pass(locked):
    """A payload entry with no file list must not read as 'nothing expected, fine'."""
    bundle, lock = locked
    data = json.loads(lock.read_text())
    del data['payloads']['wheels']['files']
    lock.write_text(json.dumps(data))
    problems = check_both(bundle, lock)
    assert problems, 'an entry with no file list let every wheel through'
    assert all('wheels/' in p for p in problems)


def test_wheelhouse_must_cover_requirements(locked):
    """The regression that motivated the check.

    A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
    sys_platform markers against the downloading machine. The lock alone cannot
    catch it, because the lock records what is there, not what is needed.
    """
    bundle, lock = locked
    app = bundle / 'app'
    app.mkdir()
    (app / 'requirements.txt').write_text(
        "alembic==1.18.4 \\\n    --hash=sha256:abc\n"
        "colorama==0.4.6 ; sys_platform == 'win32' \\\n    --hash=sha256:def\n")
    problems = check_both(bundle, lock)
    assert any('colorama==0.4.6' in p for p in problems), problems
    assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'


def test_wheelhouse_coverage_normalizes_names(locked):
    """mysql-connector-python pins as dashes and ships as mysql_connector_python."""
    bundle, lock = locked
    app = bundle / 'app'
    app.mkdir()
    (app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
    assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))

    wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
    wheel.write_bytes(b'connector')
    write_lock(bundle, lock)          # re-lock so the new wheel is not an 'extra'
    assert check_both(bundle, lock) == []


def test_payload_list_matches_powershell():
    """Both files enumerate the payload directories. Same names, same required
    flags, same order - a directory that is required in one and optional in the
    other means the builder and the installer disagree about what may be absent.
    """
    sys.path.insert(0, str(INSTALLER))
    import verify_bundle_lock as verifier

    declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
                          LOCK_PS1.read_text())
    assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
    assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]
