# SPDX-FileCopyrightText: © 2026 Joe T. Sylve, Ph.D. <joe.sylve@gmail.com>
#
# SPDX-License-Identifier: AGPL-3.0-or-later

"""Integration tests for PSpice decryption.

Test data was generated by Cadence PSpiceEnc (PSpice TI, version string "3")
from a minimal .subckt model file.  Each mode*.lib file was encrypted with
PSpiceEnc -m <N> and the mode4_userkey.lib was encrypted with -m 4 and
CDN_PSPICE_ENCKEYS pointing to a CSV with user key
``0123456789abcdefghijklmnopqrstu``.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from spice_crypt import decrypt_stream
from spice_crypt.pspice.decrypt import PSpiceFileParser, _split_block
from tests.conftest import PLAINTEXT_BODY, extract_body

DATA_DIR = Path(__file__).parent / "data" / "pspice"

USER_KEY = b"0123456789abcdefghijklmnopqrstu"


def _decrypt_file(path: Path, *, user_key: bytes | None = None) -> str:
    """Decrypt a PSpice file and return the full decrypted text."""
    content, _ = decrypt_stream(str(path), user_key=user_key)
    return content


# ---------------------------------------------------------------------------
# Decryption tests for each PSpice encryption mode
# ---------------------------------------------------------------------------


class TestPSpiceDecryption:
    """Verify decryption of PSpiceEnc-generated files across all modes."""

    def test_mode0_des_legacy(self):
        result = _decrypt_file(DATA_DIR / "mode0.lib")
        assert extract_body(result) == PLAINTEXT_BODY

    def test_mode2_des_adv(self):
        result = _decrypt_file(DATA_DIR / "mode2.lib")
        assert extract_body(result) == PLAINTEXT_BODY

    def test_mode3_aes_default(self):
        result = _decrypt_file(DATA_DIR / "mode3.lib")
        assert extract_body(result) == PLAINTEXT_BODY

    def test_mode4_aes_no_user_key(self):
        result = _decrypt_file(DATA_DIR / "mode4.lib")
        assert extract_body(result) == PLAINTEXT_BODY

    def test_mode4_aes_user_key(self):
        result = _decrypt_file(DATA_DIR / "mode4_userkey.lib", user_key=USER_KEY)
        assert extract_body(result) == PLAINTEXT_BODY

    def test_mode4_user_key_wrong_key_fails(self):
        wrong_key = b"wrong_key_padded_to_31_bytes!!!!"
        result = _decrypt_file(DATA_DIR / "mode4_userkey.lib", user_key=wrong_key)
        try:
            body = extract_body(result)
        except StopIteration:
            return  # Garbage output with no .subckt/.ends markers — decryption failed as expected
        assert body != PLAINTEXT_BODY


# ---------------------------------------------------------------------------
# Format auto-detection
# ---------------------------------------------------------------------------


class TestAutoDetection:
    """Verify that decrypt_stream auto-detects PSpice format."""

    @pytest.mark.parametrize("filename", ["mode0.lib", "mode2.lib", "mode3.lib", "mode4.lib"])
    def test_auto_detect(self, filename):
        result = _decrypt_file(DATA_DIR / filename)
        assert "R1 1 2 1k" in result


# ---------------------------------------------------------------------------
# PSpiceFileParser direct usage
# ---------------------------------------------------------------------------


class TestPSpiceFileParser:
    """Test the PSpiceFileParser class directly."""

    def test_parser_stream(self):
        with open(DATA_DIR / "mode3.lib") as f:
            parser = PSpiceFileParser(f)
            chunks = list(parser.decrypt_stream())
        text = b"".join(chunks).decode("utf-8", "replace")
        assert "R1 1 2 1k" in text

    def test_parser_with_user_key(self):
        with open(DATA_DIR / "mode4_userkey.lib") as f:
            parser = PSpiceFileParser(f, user_key_bytes=USER_KEY)
            chunks = list(parser.decrypt_stream())
        text = b"".join(chunks).decode("utf-8", "replace")
        assert "R1 1 2 1k" in text


# ---------------------------------------------------------------------------
# Block splitting and multi-block line continuation
# ---------------------------------------------------------------------------


def _block(payload62: bytes, b62: int, b63: int) -> bytes:
    """Assemble a 64-byte decrypted block from its payload and trailing bytes."""
    assert len(payload62) == 62
    return payload62 + bytes([b62, b63])


class TestSplitBlock:
    """Byte-level behaviour of ``_split_block`` for every padding layout.

    Regression coverage for the bug where padding-sentinel fragments
    (`` $jbs``, `` $jb``, …) leaked into the output and long/continued
    lines were reconstructed with wrong line breaks.
    """

    def test_full_sentinel_no_cr(self):
        # Short LF-source line: full sentinel within the payload.
        block = _block((b"R1 1 2 1k" + b" $jbs$").ljust(62, b"A"), ord("A"), 0)
        assert _split_block(block) == (b"R1 1 2 1k", True)

    def test_full_sentinel_with_cr(self):
        # CRLF-source line: the carriage return precedes the sentinel and is kept.
        block = _block((b"R1 1 2 1k\r" + b" $jbs$").ljust(62, b"A"), ord("A"), 0)
        assert _split_block(block) == (b"R1 1 2 1k\r", True)

    def test_truncated_sentinel_no_cr(self):
        # Sentinel straddles the boundary: only `` $jb`` survives, byte 62 == 's'.
        block = _block(b"A" * 58 + b" $jb", ord("s"), 0)
        assert _split_block(block) == (b"A" * 58, True)

    def test_cr_in_payload_with_truncated_sentinel(self):
        # CRLF line whose trailing sentinel is truncated: cut at the carriage return.
        block = _block(b"A" * 57 + b"\r" + b" $jb", ord("s"), 0)
        assert _split_block(block) == (b"A" * 57 + b"\r", True)

    def test_cr_at_byte_62(self):
        # Content fills the payload exactly; terminator lands in byte 62.
        block = _block(b"A" * 62, ord("\r"), 0)
        assert _split_block(block) == (b"A" * 62 + b"\r", True)

    def test_cr_at_byte_63(self):
        # Content plus terminator fills all 64 bytes; terminator in byte 63.
        block = _block(b"A" * 62, ord("1"), ord("\r"))
        assert _split_block(block) == (b"A" * 62 + b"1\r", True)

    def test_overflow_block(self):
        # 62 content bytes followed by the ``$+`` overflow marker — line continues.
        block = _block(b"A" * 62, ord("$"), ord("+"))
        assert _split_block(block) == (b"A" * 62, False)

    def test_overflow_marker_beats_truncated_sentinel(self):
        # A payload tail that looks like a sentinel prefix is NOT padding when the
        # ``$+`` overflow marker is present; the block still continues the line.
        block = _block(b"A" * 58 + b" $jb", ord("$"), ord("+"))
        assert _split_block(block) == (b"A" * 58 + b" $jb", False)


class TestLineContinuation:
    """End-to-end multi-block continuation via a DES (mode 0) round trip."""

    @staticmethod
    def _encrypt_fixture(lines: list[bytes]) -> str:
        """Encode *lines* the way PSpiceEnc would and wrap them in markers.

        Each source line is terminated with a carriage return and packed into
        64-byte blocks (overflow blocks marked ``$+``, the final block padded
        with the `` $jbs$`` sentinel), then encrypted with the mode-0 DES key.
        """
        from spice_crypt.pspice.des import PSpiceDES
        from spice_crypt.pspice.keys import derive_keys

        short_key, _ = derive_keys(0, "", None)
        cipher = PSpiceDES()
        cipher.set_key(short_key)

        def final_block(content: bytes) -> bytes:
            payload = (content + b" $jbs$").ljust(62, b"A")[:62]
            assert b" $jbs$" in payload  # content short enough to hold the sentinel
            return payload + b"A\x00"

        blocks = [b"H" * 64]  # header block (skipped during decryption)
        for line in lines:
            data = line + b"\r"
            while len(data) > 62:
                blocks.append(data[:62] + b"$+")  # overflow marker
                data = data[62:]
            blocks.append(final_block(data))

        hex_lines = [cipher.process_block(b, decrypt=False).hex() for b in blocks]
        return "$CDNENCSTART\r\n" + "\r\n".join(hex_lines) + "\r\n$CDNENCFINISH\r\n"

    def test_roundtrip_overflow_and_plus_lines(self):
        import io

        lines = [
            b"X_LONG_INST n1 n2 n3 n4 n5 n6 n7 n8 SOME_LONG_MODEL_NAME_HERE_END",
            b"+ AREA=1 TEMP=27",  # SPICE continuation line — must be preserved verbatim
            b"R1 1 2 1k",
        ]
        fixture = self._encrypt_fixture(lines)
        chunks = list(PSpiceFileParser(io.StringIO(fixture)).decrypt_stream())
        out = b"".join(chunks).decode()
        assert out == "".join(line.decode() + "\r\n" for line in lines)
        assert "$jb" not in out  # no padding-sentinel fragments leaked


# ---------------------------------------------------------------------------
# Key recovery (Mode 4 brute-force)
# ---------------------------------------------------------------------------


class TestKeyRecovery:
    """Test Mode 4 AES brute-force key recovery."""

    def test_recover_user_key(self):
        from spice_crypt.pspice import recover_mode4_key

        result = recover_mode4_key(DATA_DIR / "mode4_userkey.lib")

        assert result is not None
        assert result.user_key_full == USER_KEY

    def test_recover_no_user_key_raises(self):
        from spice_crypt.pspice import recover_mode4_key

        # Mode 4 without user key uses default keys; recovery should
        # raise ValueError indicating no brute-force is needed.
        with pytest.raises(ValueError, match="default Mode 4 keys"):
            recover_mode4_key(DATA_DIR / "mode4.lib")
