"""Signed plugin shelf: index build/verify, anti-rollback state, adopt mechanics
(ADR-013 Phase 2).

The shelf is a read-only folder of .shopdbplugin artifacts plus a signed
shelf-index.json (+ .sig). The index carries a monotonic serial (a site refuses
an index older than the last it saw) and a revoked list. Browse reads the index;
ADOPT always re-verifies the artifact's own signed manifest, never trusting the
index for anything security-bearing.
"""

import hashlib
import json
import shutil
import zipfile
from pathlib import Path

from packaging.version import Version, InvalidVersion

from . import signing, packaging


def _sha256_file(path) -> str:
    digest = hashlib.sha256()
    with open(path, 'rb') as handle:
        for chunk in iter(lambda: handle.read(65536), b''):
            digest.update(chunk)
    return digest.hexdigest()

INDEX_NAME = 'shelf-index.json'
INDEX_SIG = 'shelf-index.sig'
STATE_NAME = 'shelf-state.json'
STAGING_DIR = '.staging'


def _read_artifact_manifest(artifact_path) -> dict:
    with zipfile.ZipFile(artifact_path) as archive:
        return json.loads(archive.read('manifest.json'))


def build_index(shelf_dir, private_key, serial: int,
                publisher: str = '', revoked=None) -> dict:
    """Scan the shelf for artifacts, build and SIGN the index. Caller supplies a
    serial that must exceed any previously published one."""
    shelf_dir = Path(shelf_dir)
    plugins = {}
    for artifact in sorted(shelf_dir.glob(f'*{packaging.ARTIFACT_SUFFIX}')):
        manifest = _read_artifact_manifest(artifact)
        plugins.setdefault(manifest['name'], []).append({
            'version': manifest['version'],
            'tier': manifest.get('tier', 'optional'),
            'core_version': manifest.get('core_version', ''),
            'artifact': artifact.name,
            # Bind the entry to the artifact CONTENT, not just a filename, so an
            # attacker who can rewrite the shelf folder cannot serve different
            # (e.g. revoked) bytes under a live filename (finding #4).
            'sha256': _sha256_file(artifact),
        })

    index = {
        'serial': int(serial),
        'publisher': publisher or '',
        'plugins': plugins,
        'revoked': list(revoked or []),
    }
    index_bytes = signing.serialize_provenance(index)  # canonical, sorted bytes
    (shelf_dir / INDEX_NAME).write_bytes(index_bytes)
    (shelf_dir / INDEX_SIG).write_bytes(signing.sign(private_key, index_bytes))
    return index


def load_index(shelf_dir, public_keys):
    """(index_or_None, errors). Verifies the index signature. Serial-vs-state is
    the caller's check (needs the instance path)."""
    shelf_dir = Path(shelf_dir)
    index_path = shelf_dir / INDEX_NAME
    sig_path = shelf_dir / INDEX_SIG
    if not index_path.exists() or not sig_path.exists():
        return None, ['shelf has no signed index (shelf-index.json/.sig)']

    index_bytes = index_path.read_bytes()
    signature = sig_path.read_bytes()

    errors = []
    if not public_keys:
        errors.append('no trusted keys - shelf index unverifiable')
    elif not signing.verify(public_keys, index_bytes, signature):
        errors.append('shelf index signature does not match any trusted key')

    try:
        index = json.loads(index_bytes)
    except json.JSONDecodeError as exc:
        return None, errors + [f'shelf index is not valid JSON: {exc}']
    return index, errors


def read_last_serial(instance_path) -> int:
    state = Path(instance_path) / STATE_NAME
    if state.exists():
        try:
            return int(json.loads(state.read_text()).get('last_serial', -1))
        except (json.JSONDecodeError, ValueError, TypeError):
            return -1
    return -1


def write_last_serial(instance_path, serial: int) -> None:
    state = Path(instance_path) / STATE_NAME
    state.parent.mkdir(parents=True, exist_ok=True)
    state.write_text(json.dumps({'last_serial': int(serial)}, indent=2))


def is_revoked(index: dict, name: str, version: str) -> bool:
    for entry in index.get('revoked', []):
        if entry.get('name') == name and entry.get('version') == version:
            return True
    return False


def _safe_version(value):
    try:
        return Version(value)
    except InvalidVersion:
        return None


def resolve_version(index: dict, name: str, version: str = None):
    """Pick the artifact for name[==version] from the index.

    With no version, picks the highest non-revoked version. Returns
    (version, artifact_filename, sha256_or_None) or raises KeyError/ValueError.
    """
    candidates = index.get('plugins', {}).get(name)
    if not candidates:
        raise KeyError(f'plugin {name} not on the shelf')

    if version is not None:
        for entry in candidates:
            if entry['version'] == version:
                if is_revoked(index, name, version):
                    raise ValueError(f'{name} {version} is revoked')
                return version, entry['artifact'], entry.get('sha256')
        raise KeyError(f'{name} {version} not on the shelf')

    live = [e for e in candidates if not is_revoked(index, name, e['version'])]
    if not live:
        raise ValueError(f'every published version of {name} is revoked')
    best = max(live, key=lambda e: (_safe_version(e['version']) or Version('0'),))
    return best['version'], best['artifact'], best.get('sha256')


def unpack_verified(artifact_path, public_keys, plugins_dir, name: str,
                    expected_version: str = None, expected_sha256: str = None):
    """Verify an artifact, then atomically place it at plugins_dir/name.

    Binds the delivered bytes to what the signed index resolved: the file digest
    must match expected_sha256 (when given) and the artifact's OWN signed
    manifest must carry name (and expected_version, when given). This stops a
    revoked/other version being served under a live filename (finding #4).
    Verifies signature + every file hash in staging BEFORE anything can import;
    only a fully verified tree is moved into place. Raises ValueError on any
    failure.
    """
    plugins_dir = Path(plugins_dir)

    if expected_sha256 is not None:
        actual_sha = _sha256_file(artifact_path)
        if actual_sha != expected_sha256:
            raise ValueError(
                'artifact bytes do not match the signed index digest')

    manifest, errors = packaging.verify_artifact(artifact_path, public_keys)
    if errors:
        raise ValueError('; '.join(errors))
    if manifest.get('name') != name:
        raise ValueError(
            f"artifact manifest name '{manifest.get('name')}' != '{name}'")
    if expected_version is not None and manifest.get('version') != expected_version:
        raise ValueError(
            f"artifact version '{manifest.get('version')}' != resolved "
            f"'{expected_version}' (bytes served under wrong filename?)")

    staging = plugins_dir / STAGING_DIR
    staging.mkdir(parents=True, exist_ok=True)
    staged = staging / name
    if staged.exists():
        shutil.rmtree(staged)

    with zipfile.ZipFile(artifact_path) as archive:
        archive.extractall(staged)

    # Re-verify the unpacked tree before it goes live (defense against an
    # extract that did not land what we verified).
    _, staged_errors = packaging.verify_dir(staged, public_keys)
    if staged_errors:
        shutil.rmtree(staged, ignore_errors=True)
        raise ValueError('staged tree failed re-verification: '
                         + '; '.join(staged_errors))

    target = plugins_dir / name
    backup = staging / f'{name}.backup'
    if target.exists():
        if backup.exists():
            shutil.rmtree(backup)
        target.rename(backup)
    try:
        staged.rename(target)
    except OSError:
        # cross-device or rename race: fall back to copy, keep backup on failure
        shutil.copytree(staged, target)
        shutil.rmtree(staged, ignore_errors=True)
    if backup.exists():
        shutil.rmtree(backup, ignore_errors=True)
    return target
