"""OSDP framing, the same bytes as edge/wardn-osdp.

Runs unchanged on the Pico (MicroPython) and on the Mac (Python 3).
Clear OSDP only, with CRC. No Secure Channel.
"""

from collections import namedtuple

SOM = 0x53
REPLY = 0x80

POLL = 0x60
ID = 0x61
CAP = 0x62
ISTAT = 0x65
OSTAT = 0x66
OUT = 0x68
LED = 0x69
BUZ = 0x6A
TEXT = 0x6B

ACK = 0x40
NAK = 0x41
PDID = 0x45
PDCAP = 0x46
ISTATR = 0x49
OSTATR = 0x4A
RAW = 0x50
KEYPAD = 0x53

HEADER = 5
CRC_LEN = 2
SMALLEST = HEADER + 1 + CRC_LEN
LARGEST = 512

OFF, RED, GREEN, AMBER = 0, 1, 2, 3
TONE_OFF, TONE_DEFAULT = 1, 2
OUTPUT_OFF, OUTPUT_ON, OUTPUT_PULSE = 1, 2, 5

Frame = namedtuple("Frame", ("address", "sequence", "code", "data"))


def crc16(data):
    """CRC-16/AUG-CCITT: polynomial 0x1021 seeded with 0x1D0F, no reflection."""
    crc = 0x1D0F
    for byte in data:
        crc ^= byte << 8
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else crc << 1
        crc &= 0xFFFF
    return crc


def build(address, sequence, code, data=b""):
    """One frame, ready for the wire. Bit 7 of address marks a reply."""
    length = SMALLEST + len(data)
    frame = bytes([SOM, address, length & 0xFF, length >> 8, (sequence & 0x03) | 0x04, code]) + bytes(data)
    crc = crc16(frame)
    return frame + bytes([crc & 0xFF, crc >> 8])


def reply(address, sequence, code, data=b""):
    return build(address | REPLY, sequence, code, data)


def parse(buffer):
    """Takes the first frame out of buffer.

    Returns (frame, rest). frame is None while no complete frame is there.
    Noise before a start of message is dropped. So is a start of message
    whose frame fails its CRC, and reading resumes on the byte after it.
    """
    start = buffer.find(bytes([SOM]))
    buffer = buffer[start:] if start >= 0 else b""
    if len(buffer) < HEADER:
        return None, buffer

    length = buffer[2] | (buffer[3] << 8)
    plausible = SMALLEST <= length <= LARGEST
    if plausible and len(buffer) < length:
        return None, buffer

    frame = buffer[:length]
    if not plausible or not is_valid(frame):
        return None, buffer[1:]
    return Frame(frame[1], frame[4] & 0x03, frame[HEADER], frame[HEADER + 1 : -CRC_LEN]), buffer[length:]


def is_valid(frame):
    uses_crc = frame[4] & 0x04 != 0
    return uses_crc and crc16(frame[:-CRC_LEN]) == frame[-2] | (frame[-1] << 8)


def led_temporary(color, deciseconds, reader=0, led=0):
    """osdp_LED data: show color for deciseconds, then go back to the permanent colour."""
    return bytes([reader, led, 2, 0, 0, color, OFF, deciseconds & 0xFF, deciseconds >> 8, 0, 0, 0, 0, 0])


def buzzer(on_deciseconds, off_deciseconds, count, reader=0):
    """osdp_BUZ data: beep count times with the default tone."""
    return bytes([reader, TONE_DEFAULT, on_deciseconds, off_deciseconds, count])


def text(message, row=1, reader=0):
    """osdp_TEXT data: one permanent line, without wrapping."""
    encoded = message.encode()[:255]
    return bytes([reader, 1, 0, row, 1, len(encoded)]) + encoded


def output(control, deciseconds=0, number=0):
    """osdp_OUT data for one output.

    OUTPUT_OFF and OUTPUT_ON set the resting state and cancel a pulse.
    OUTPUT_PULSE turns it on for deciseconds, then back to its resting state.
    """
    return bytes([number, control, deciseconds & 0xFF, deciseconds >> 8])
