"""Signed shelf index + adopt-mechanics tests (ADR-013 Phase 2).

Covers: a signed index round-trips and a tampered/wrong-key index is rejected;
serial anti-rollback state; revocation; version resolution; and unpack_verified
placing only a fully verified tree (refusing a tampered artifact).
"""

import json
import zipfile

import pytest

from shopdb.plugins import signing, packaging, shelf


def _write_plugin(plugin_dir, name='demo', version='1.0.0', tier=None):
    plugin_dir.mkdir(parents=True, exist_ok=True)
    manifest = {
        'name': name, 'version': version, 'description': 'demo',
        'dependencies': [], 'core_version': '>=0.1.0,<1.0.0',
        'api_prefix': f'/api/{name}',
    }
    if tier:
        manifest['tier'] = tier
    (plugin_dir / 'manifest.json').write_text(json.dumps(manifest))
    (plugin_dir / 'plugin.py').write_text('# demo\n')
    return plugin_dir


@pytest.fixture
def keypair(tmp_path):
    private_pem, public_pem = signing.generate_keypair()
    return (signing.load_private_key(private_pem),
            signing.load_public_key(public_pem))


def _build_shelf(tmp_path, private_key, specs, serial=1, revoked=None):
    """specs: list of (name, version). Packs each into a shelf dir + index."""
    shelf_dir = tmp_path / 'shelf'
    shelf_dir.mkdir(parents=True, exist_ok=True)
    for name, version in specs:
        src = tmp_path / 'src' / f'{name}-{version}'
        _write_plugin(src, name=name, version=version)
        packaging.pack(src, private_key, out_dir=shelf_dir)
    shelf.build_index(shelf_dir, private_key, serial, revoked=revoked)
    return shelf_dir


# --- index sign/verify ------------------------------------------------------

def test_index_round_trip(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
    index, errors = shelf.load_index(shelf_dir, [pub])
    assert errors == []
    assert index['serial'] == 1
    assert 'demo' in index['plugins']


def test_tampered_index_rejected(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
    # change the index bytes but keep the old signature
    path = shelf_dir / shelf.INDEX_NAME
    data = json.loads(path.read_text())
    data['serial'] = 999
    path.write_bytes(signing.serialize_provenance(data))
    _, errors = shelf.load_index(shelf_dir, [pub])
    assert any('signature does not match' in e for e in errors)


def test_index_wrong_key_rejected(tmp_path, keypair):
    priv, _ = keypair
    shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
    wrong_pub = signing.load_public_key(signing.generate_keypair()[1])
    _, errors = shelf.load_index(shelf_dir, [wrong_pub])
    assert any('signature does not match' in e for e in errors)


def test_index_no_keys_unverifiable(tmp_path, keypair):
    priv, _ = keypair
    shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
    _, errors = shelf.load_index(shelf_dir, [])
    assert any('unverifiable' in e for e in errors)


# --- anti-rollback state ----------------------------------------------------

def test_serial_state_round_trip(tmp_path):
    assert shelf.read_last_serial(tmp_path) == -1
    shelf.write_last_serial(tmp_path, 5)
    assert shelf.read_last_serial(tmp_path) == 5


# --- revocation + version resolution ----------------------------------------

def test_resolve_highest_non_revoked(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(
        tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')])
    index, _ = shelf.load_index(shelf_dir, [pub])
    version, artifact, sha = shelf.resolve_version(index, 'demo')
    assert version == '1.2.0'
    assert artifact == 'demo-1.2.0.shopdbplugin'
    assert sha and len(sha) == 64


def test_resolve_skips_revoked(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(
        tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')],
        revoked=[{'name': 'demo', 'version': '1.2.0'}])
    index, _ = shelf.load_index(shelf_dir, [pub])
    version, _, _ = shelf.resolve_version(index, 'demo')
    assert version == '1.0.0'


def test_resolve_explicit_revoked_raises(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(
        tmp_path, priv, [('demo', '1.0.0')],
        revoked=[{'name': 'demo', 'version': '1.0.0'}])
    index, _ = shelf.load_index(shelf_dir, [pub])
    with pytest.raises(ValueError):
        shelf.resolve_version(index, 'demo', '1.0.0')


def test_resolve_unknown_raises(tmp_path, keypair):
    priv, pub = keypair
    shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
    index, _ = shelf.load_index(shelf_dir, [pub])
    with pytest.raises(KeyError):
        shelf.resolve_version(index, 'nope')


# --- verified atomic unpack -------------------------------------------------

def test_unpack_verified_places_tree(tmp_path, keypair):
    priv, pub = keypair
    src = _write_plugin(tmp_path / 'src' / 'demo')
    artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')
    plugins_dir = tmp_path / 'live'
    plugins_dir.mkdir()

    target = shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo')
    assert target == plugins_dir / 'demo'
    assert (target / 'manifest.json').exists()
    assert (target / signing.PROVENANCE_NAME).exists()
    # staging cleaned up
    assert not (plugins_dir / shelf.STAGING_DIR / 'demo').exists()


def test_unpack_verified_refuses_tampered(tmp_path, keypair):
    priv, pub = keypair
    src = _write_plugin(tmp_path / 'src' / 'demo')
    artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')

    # tamper a member, keep provenance + sig
    with zipfile.ZipFile(artifact) as zin:
        members = {n: zin.read(n) for n in zin.namelist()}
    members['manifest.json'] += b'tampered'
    with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as zout:
        for name, data in members.items():
            zout.writestr(name, data)

    plugins_dir = tmp_path / 'live'
    plugins_dir.mkdir()
    with pytest.raises(ValueError):
        shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo')
    # nothing placed
    assert not (plugins_dir / 'demo').exists()


def test_unpack_verified_wrong_name_refused(tmp_path, keypair):
    priv, pub = keypair
    src = _write_plugin(tmp_path / 'src' / 'demo')
    artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')
    plugins_dir = tmp_path / 'live'
    plugins_dir.mkdir()
    with pytest.raises(ValueError):
        shelf.unpack_verified(artifact, [pub], plugins_dir, 'somethingelse')


def test_revoked_bytes_under_live_filename_refused(tmp_path, keypair):
    """Finding #4: an attacker swaps the genuinely-signed bytes of a revoked
    1.0.0 into the file named for the live 2.0.0. Adopt must refuse because the
    artifact's own signed version (and content digest) do not match the resolved
    version."""
    priv, pub = keypair
    shelf_dir = tmp_path / 'shelf'
    shelf_dir.mkdir()
    # pack both versions
    for version in ('1.0.0', '2.0.0'):
        src = _write_plugin(tmp_path / 'src' / version, version=version)
        packaging.pack(src, priv, out_dir=shelf_dir)
    # index revokes 1.0.0, records true digests for 2.0.0
    shelf.build_index(shelf_dir, priv, 1,
                      revoked=[{'name': 'demo', 'version': '1.0.0'}])
    index, _ = shelf.load_index(shelf_dir, [pub])

    resolved_version, artifact_name, expected_sha = shelf.resolve_version(
        index, 'demo')
    assert resolved_version == '2.0.0'

    # attacker overwrites the 2.0.0 file with the signed 1.0.0 bytes
    live = shelf_dir / artifact_name
    (shelf_dir / 'demo-1.0.0.shopdbplugin').replace(live)

    plugins_dir = tmp_path / 'live'
    plugins_dir.mkdir()
    with pytest.raises(ValueError):
        shelf.unpack_verified(live, [pub], plugins_dir, 'demo',
                              expected_version=resolved_version,
                              expected_sha256=expected_sha)
    assert not (plugins_dir / 'demo').exists()
