#!/usr/bin/env python3
"""
Offline unit tests for the parser's device-identification path.

Covers two things the offline (no-server) pipeline is responsible for:

  1. BinRAW -> RAW identification route (commit 4396c74): a BinRAW capture has
     no RAW_Data array, so the parser must reconstruct a signed pulse train from
     the demodulated Data_RAW bit stream. That reconstruction is what lets a
     BinRAW file flow through the SAME identification path as a RAW file
     (``has_raw_data`` becomes true), yielding device matches.

  2. Per-match catalog-category surfacing (commit 41850b5): every device match
     the engine returns must carry its OWN catalog category in
     ``match_details['device_category']`` (e.g. an Acurite sensor stays a
     "Weather Sensor"), not just the single ML overall call.

Everything here is author-time / offline: bare ``pytest`` runs it with no
Docker, no bound server, and no network. ``.sub`` fixtures are written to the
per-test ``tmp_path`` because ``*.sub`` is git-ignored repo-wide, so embedding
the content keeps the suite green on a fresh clone.
"""

import pytest

from src.parser.sub_parser import SubFileParser
from src.matcher.engine import SignatureMatcher
from src.matcher.pattern_decoder import PatternDecoder
from src.matcher.protocol_database import get_protocol_database


# --- minimal, self-contained fixtures (written to tmp_path at runtime) -------

# BinRAW: alternating bit stream (0xAA == 0b10101010) x 6 bytes = 48 bits.
# Alternating bits mean every run has length 1, so the reconstructed pulse
# train is exactly 48 pulses, each of magnitude TE (495 us), sign-alternating.
BINRAW_SUB = "\n".join([
    "Filetype: Flipper SubGhz Key File",
    "Version: 1",
    "Frequency: 868350000",
    "Preset: FuriHalSubGhzPresetOok270Async",
    "Protocol: BinRAW",
    "Bit: 48",
    "TE: 495",
    "Bit_RAW: 48",
    "Data_RAW: AA AA AA AA AA AA",
    "",
])

# RAW: a small but real timing array (315 MHz OOK).
RAW_SUB = "\n".join([
    "Filetype: Flipper SubGhz RAW File",
    "Version: 1",
    "Frequency: 315000000",
    "Preset: FuriHalSubGhzPresetOok650Async",
    "Protocol: RAW",
    "RAW_Data: 2980 -240 520 -980 520 -980 1000 -500 480 -1020 520 -980 1000 -500",
    "",
])


def _write(tmp_path, name, content):
    p = tmp_path / name
    p.write_text(content)
    return str(p)


def _catalog_categories():
    """Authoritative {protocol_name: catalog_category} from the protocol DB."""
    return {proto.name: proto.category for proto in get_protocol_database().get_all()}


def _valid_categories():
    """The set of every catalog category the protocol DB knows about."""
    return {proto.category for proto in get_protocol_database().get_all()}


# --- BinRAW parsing ----------------------------------------------------------

class TestBinRawParsing:
    def test_binraw_fields_extracted(self, tmp_path):
        meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
        assert meta.file_format == "BinRAW"
        assert meta.frequency == 868350000
        assert meta.modulation == "OOK"
        assert meta.bin_raw_te == 495
        assert meta.bin_raw_bit == 48
        assert meta.bin_raw_data == bytes.fromhex("AAAAAAAAAAAA")

    def test_binraw_reconstructs_raw_pulse_train(self, tmp_path):
        """The BinRAW->RAW route: Data_RAW must expand into raw_data pulses."""
        meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
        assert meta.has_raw_data is True
        # 48 alternating bits -> 48 unit-length runs -> 48 pulses.
        assert meta.pulse_count == 48
        assert len(meta.raw_data) == 48
        # Every pulse is exactly one TE, alternating sign.
        assert all(abs(t) == 495 for t in meta.raw_data)
        assert meta.raw_data[0] > 0 and meta.raw_data[1] < 0


class TestBinRawToPulses:
    """The run-length expansion is deterministic; test it directly."""

    def test_run_length_encoding(self):
        # 0xF0 == 11110000 -> run of 4 ones then 4 zeros.
        pulses = SubFileParser._binraw_to_pulses(bytes([0xF0]), 8, 100)
        assert pulses == [400, -400]

    def test_alternating_bits(self):
        # 0xAA == 10101010 -> 8 unit runs, alternating sign starting high.
        pulses = SubFileParser._binraw_to_pulses(bytes([0xAA]), 8, 100)
        assert pulses == [100, -100, 100, -100, 100, -100, 100, -100]

    def test_truncates_to_n_bits(self):
        # Only the first 4 bits (1111) of 0xF0 are used.
        pulses = SubFileParser._binraw_to_pulses(bytes([0xF0]), 4, 50)
        assert pulses == [200]


# --- RAW parsing -------------------------------------------------------------

class TestRawParsing:
    def test_raw_fields_extracted(self, tmp_path):
        meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
        assert meta.file_format == "RAW"
        assert meta.frequency == 315000000
        assert meta.modulation == "OOK"
        assert meta.has_raw_data is True
        assert meta.pulse_count == 14
        assert meta.raw_data[0] == 2980
        assert meta.avg_pulse_width > 0


# --- identification: BinRAW flows through RAW, categories surface -------------

class TestBinRawIdentificationRoute:
    def test_binraw_yields_matches_via_raw_path(self, tmp_path):
        """A BinRAW capture identifies at all only because of the RAW route."""
        meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
        matches = SignatureMatcher().match(meta, max_results=5)
        assert len(matches) > 0
        for m in matches:
            assert m.device_name  # non-empty device name
            cat = m.match_details.get("device_category")
            assert isinstance(cat, str) and cat, \
                "every match must surface a per-device catalog category"

    def test_binraw_category_is_valid_catalog_category(self, tmp_path):
        """Every surfaced device_category is a real category from the catalog."""
        meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
        valid = _valid_categories()
        matches = SignatureMatcher().match(meta, max_results=5)
        assert len(matches) > 0
        for m in matches:
            assert m.match_details.get("device_category") in valid

    def test_binraw_decoder_category_is_protocol_catalog_category(self, tmp_path):
        """At the source, DeviceMatch.category (surfaced by the engine) == protocol.category."""
        meta = SubFileParser().parse(_write(tmp_path, "b.sub", BINRAW_SUB))
        decoded = PatternDecoder().decode(meta)
        assert len(decoded) > 0
        for dm in decoded:
            assert dm.category == dm.protocol.category


class TestRawIdentificationCategory:
    def test_raw_match_surfaces_catalog_category(self, tmp_path):
        meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
        catalog = _catalog_categories()
        matches = SignatureMatcher().match(meta, max_results=5)
        assert len(matches) > 0
        checked = 0
        for m in matches:
            cat = m.match_details.get("device_category")
            assert isinstance(cat, str) and cat
            if m.device_name in catalog:
                assert cat == catalog[m.device_name]
                checked += 1
        assert checked > 0

    def test_decoder_category_is_protocol_catalog_category(self, tmp_path):
        """DeviceMatch.category (the source of the surfaced value) == protocol.category."""
        meta = SubFileParser().parse(_write(tmp_path, "r.sub", RAW_SUB))
        decoded = PatternDecoder().decode(meta)
        assert len(decoded) > 0
        for dm in decoded:
            assert dm.category == dm.protocol.category
