#!/usr/bin/env python3
"""Check a staged installer bundle against bundle-lock.json.

Prints one line per problem and exits non-zero if there are any. Exits 0 only
when the bundle's third-party payload is EXACTLY what the lock describes: no
missing file, no unexpected extra file, no changed content.

Why this exists alongside bundle-lock.ps1, which does the same job:

  - bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server,
    where PowerShell is the only thing guaranteed to be present - Python is not
    installed until stage 2, and verifying the payload after running part of it
    would defeat the purpose.
  - This file lets the Linux builder (build-installer.sh) do the same check
    without adding pwsh as a build dependency.

The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against
the same fixtures and fails if they disagree.

Usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>
"""
import hashlib
import json
import os
import re
import sys

# Must match $script:BundlePayloads in bundle-lock.ps1.
PAYLOADS = [
    ('wheels',              True,  'Python wheels for the offline install'),
    ('python',              True,  'the Python installer'),
    ('httpplatformhandler', True,  'the IIS module that launches waitress'),
    ('urlrewrite',          False, 'IIS URL Rewrite, for the client-IP rule'),
    ('mysqlclient',         False, 'mysql/mysqldump, for backups against a remote database'),
    ('vcredist',            False, 'the Visual C++ runtime MySQL requires'),
    ('mysql',               False, 'MySQL, for the bundled-database option'),
]


def digest(path):
    sha = hashlib.sha256()
    with open(path, 'rb') as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b''):
            sha.update(chunk)
    return sha.hexdigest()


def payload_files(directory):
    """Every file under the directory, keyed by forward-slashed relative path."""
    found = {}
    if not os.path.isdir(directory):
        return found
    for root, _dirs, files in os.walk(directory):
        for name in files:
            full = os.path.join(root, name)
            rel = os.path.relpath(full, directory).replace(os.sep, '/')
            found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)}
    return found


def normalize(name):
    """PEP 427 wheel filename form: runs of non-alphanumerics become one _."""
    return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()


def requirement_pins(requirements_path):
    """Every 'name==version' pinned in a lockfile, including marked-out ones.

    Markers are deliberately IGNORED. A requirement guarded by
    sys_platform == 'win32' is exactly the case that must be present, because the
    target is Windows and the wheelhouse is usually assembled somewhere else.
    """
    pins = {}
    with open(requirements_path) as fh:
        for line in fh:
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line)
            if match:
                pins[normalize(match.group(1))] = match.group(2)
    return pins


def check_wheelhouse_covers_requirements(bundle_root):
    """The lock records what IS in the wheelhouse, not what the app NEEDS.

    Without this, an incomplete wheelhouse gets locked and blessed, and the
    install fails on an air-gapped server. That is not hypothetical: assembling
    the wheelhouse on Linux silently omits colorama, a win32-only dependency of
    click, because pip evaluates environment markers against the machine doing
    the downloading rather than the machine being targeted.
    """
    wheels = os.path.join(bundle_root, 'wheels')
    requirements = os.path.join(bundle_root, 'app', 'requirements.txt')
    if not os.path.isdir(wheels) or not os.path.exists(requirements):
        return []
    have = os.listdir(wheels)
    problems = []
    for name, version in sorted(requirement_pins(requirements).items()):
        prefix = '%s-%s-' % (name, version)
        if not any(f.lower().startswith(prefix) for f in have):
            problems.append(
                'wheels/ has no wheel for %s==%s, which requirements.txt pins '
                '(a marked-out dependency still installs on Windows)' % (name, version))
    return problems


def verify(bundle_root, lock):
    problems = []
    locked = lock.get('payloads')
    if not locked:
        return ['bundle-lock.json has no "payloads" section']

    for name, required, what in PAYLOADS:
        directory = os.path.join(bundle_root, name)
        present = os.path.isdir(directory)
        if name not in locked:
            if present:
                problems.append(
                    '%s/ is present but is not in bundle-lock.json - regenerate the lock' % name)
            elif required:
                problems.append(
                    '%s/ is required but is in neither the bundle nor the lock' % name)
            continue
        if not present:
            if required or locked[name].get('required'):
                problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what))
            continue

        expected = locked[name].get('files', {})
        actual = payload_files(directory)
        for rel, want in sorted(expected.items()):
            got = actual.get(rel)
            if got is None:
                problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel))
            elif got['sha256'] != want['sha256']:
                problems.append(
                    '%s/%s does NOT match the lock (expected sha256 %s..., got %s...)'
                    % (name, rel, want['sha256'][:12], got['sha256'][:12]))
            elif int(got['size']) != int(want['size']):
                # Impossible for a matching sha256, so the lock was hand-edited.
                problems.append(
                    '%s/%s size disagrees with the lock - the lock has been edited by hand'
                    % (name, rel))
        for rel in sorted(actual):
            if rel not in expected:
                problems.append(
                    '%s/%s is in the bundle but NOT in the lock (unexpected extra file)'
                    % (name, rel))
    problems.extend(check_wheelhouse_covers_requirements(bundle_root))
    return problems


def main():
    if len(sys.argv) != 3:
        sys.exit('usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>')
    bundle_root, lock_path = sys.argv[1], sys.argv[2]
    if not os.path.exists(lock_path):
        print('no bundle-lock.json at %s' % lock_path)
        return 1
    with open(lock_path) as fh:
        lock = json.load(fh)
    problems = verify(bundle_root, lock)
    for problem in problems:
        print(problem)
    return 1 if problems else 0


if __name__ == '__main__':
    sys.exit(main())
