"""Access code management for Kwikset smart locks.

This module provides TLV8 encoding logic and data types for creating,
editing, and deleting access codes on Kwikset smart lock devices.
The protocol was reverse-engineered from the Kwikset Android APK (v2.9.0).
"""

from __future__ import annotations

import base64
from dataclasses import dataclass
from enum import IntEnum, IntFlag


class ScheduleType(IntEnum):
    """Access code schedule types."""

    ALL_DAY = 1
    DATE_RANGE = 3
    WEEKLY = 4
    ONE_TIME_UNLIMITED = 5
    ONE_TIME_24_HOUR = 6


class DayOfWeek(IntFlag):
    """Days of week bitmask for weekly schedules."""

    SUNDAY = 0x01
    MONDAY = 0x02
    TUESDAY = 0x04
    WEDNESDAY = 0x08
    THURSDAY = 0x10
    FRIDAY = 0x20
    SATURDAY = 0x40


class _Tlv8SubType(IntEnum):
    """TLV8 sub-type identifiers (internal use)."""

    ACCESS_CODE = 0x00
    SCHEDULE = 0x01
    FRIENDLY_NAME = 0x02


class _TxCommand(IntEnum):
    """Device access TX command types (internal use)."""

    SET_ACCESS_CODE = 0x04
    DELETE_DEVICE_ACCESS = 0x05
    DELETE_ALL_ACCESS_CODES = 0x0A
    EDIT_ACCESS_CODE = 0x71


@dataclass(frozen=True)
class AccessCodeSchedule:
    """Schedule configuration for an access code.

    :param schedule_type: The type of schedule for this access code
    :type schedule_type: ``ScheduleType``
    :param start_hour: Start hour (0-23), used for DATE_RANGE and WEEKLY
    :type start_hour: ``int``
    :param start_minute: Start minute (0-59), used for DATE_RANGE and WEEKLY
    :type start_minute: ``int``
    :param end_hour: End hour (0-23), used for DATE_RANGE and WEEKLY
    :type end_hour: ``int``
    :param end_minute: End minute (0-59), used for DATE_RANGE and WEEKLY
    :type end_minute: ``int``
    :param start_month: Start month (1-12), used for DATE_RANGE
    :type start_month: ``int``
    :param start_day: Start day (1-31), used for DATE_RANGE
    :type start_day: ``int``
    :param start_year: Start year (2000-2127), used for DATE_RANGE
    :type start_year: ``int``
    :param end_month: End month (1-12), used for DATE_RANGE
    :type end_month: ``int``
    :param end_day: End day (1-31), used for DATE_RANGE
    :type end_day: ``int``
    :param end_year: End year (2000-2127), used for DATE_RANGE
    :type end_year: ``int``
    :param days_of_week: Bitmask of active days, used for WEEKLY
    :type days_of_week: ``Optional[DayOfWeek]``
    """

    schedule_type: ScheduleType
    start_hour: int = 0
    start_minute: int = 0
    end_hour: int = 23
    end_minute: int = 59
    start_month: int = 1
    start_day: int = 1
    start_year: int = 2025
    end_month: int = 12
    end_day: int = 31
    end_year: int = 2025
    days_of_week: DayOfWeek | None = None


@dataclass(frozen=True)
class AccessCodeResult:
    """Result from an access code create/edit operation.

    :param token: Token identifier for tracking the operation
    :type token: ``str``
    :param last_update_status: Timestamp of the last update status
    :type last_update_status: ``int``
    """

    token: str
    last_update_status: int


def encode_bcd(code: str) -> bytes:
    """Encode a numeric string into BCD (Binary-Coded Decimal) bytes.

    Each pair of digits is packed into one byte (high nibble first).
    Odd-length strings pad the last nibble with 0xF.

    :param code: Numeric string of 4-8 digits
    :type code: ``str``
    :raises ValueError: If code is not 4-8 digits or contains non-digit characters
    :rtype: ``bytes``
    """
    if not code or not code.isdigit():
        raise ValueError("Access code must be a non-empty numeric string")
    if not 4 <= len(code) <= 8:
        raise ValueError(f"Access code must be 4-8 digits, got {len(code)}")

    result = bytearray()
    for i in range(0, len(code), 2):
        high = int(code[i])
        low = int(code[i + 1]) if i + 1 < len(code) else 0xF
        result.append((high << 4) | low)
    return bytes(result)


def _pack_time(
    start_hour: int,
    start_minute: int,
    end_hour: int,
    end_minute: int,
) -> bytes:
    """Pack start/end times into 3 bytes.

    Bit layout:
      byte[0] = start_minute << 2
      byte[1] = (end_minute & 0x3F) | (start_hour << 6)
      byte[2] = (start_hour >> 2) | (end_hour << 3)

    :param start_hour: Start hour (0-23)
    :type start_hour: ``int``
    :param start_minute: Start minute (0-59)
    :type start_minute: ``int``
    :param end_hour: End hour (0-23)
    :type end_hour: ``int``
    :param end_minute: End minute (0-59)
    :type end_minute: ``int``
    :rtype: ``bytes``
    """
    b0 = (start_minute << 2) & 0xFF
    b1 = ((end_minute & 0x3F) | (start_hour << 6)) & 0xFF
    b2 = ((start_hour >> 2) | (end_hour << 3)) & 0xFF
    return bytes([b0, b1, b2])


def _pack_date(
    start_month: int,
    start_day: int,
    start_year: int,
    end_month: int,
    end_day: int,
    end_year: int,
) -> bytes:
    """Pack start/end dates into 4 little-endian bytes.

    Bit layout in 32-bit LE integer:
      bits[0:4]   = start_month
      bits[4:8]   = end_month
      bits[8:13]  = start_day
      bits[13:18] = end_day
      bits[18:25] = start_year - 2000
      bits[25:32] = end_year - 2000

    :param start_month: Start month (1-12)
    :type start_month: ``int``
    :param start_day: Start day (1-31)
    :type start_day: ``int``
    :param start_year: Start year (2000-2127)
    :type start_year: ``int``
    :param end_month: End month (1-12)
    :type end_month: ``int``
    :param end_day: End day (1-31)
    :type end_day: ``int``
    :param end_year: End year (2000-2127)
    :type end_year: ``int``
    :rtype: ``bytes``
    """
    value = (
        start_month
        | (end_month << 4)
        | (start_day << 8)
        | (end_day << 13)
        | ((start_year - 2000) << 18)
        | ((end_year - 2000) << 25)
    )
    return value.to_bytes(4, byteorder="little")


def _build_tlv8_record(sub_type: int, data: bytes) -> bytes:
    """Build a TLV8 record: [type, length, ...data].

    :param sub_type: TLV8 sub-type identifier
    :type sub_type: ``int``
    :param data: Raw data bytes for the record
    :type data: ``bytes``
    :rtype: ``bytes``
    """
    return bytes([sub_type, len(data)]) + data


def _encode_schedule(schedule: AccessCodeSchedule) -> bytes:
    """Encode schedule into TLV8 data bytes (no TLV wrapper).

    :param schedule: Schedule configuration to encode
    :type schedule: ``AccessCodeSchedule``
    :rtype: ``bytes``
    """
    if schedule.schedule_type == ScheduleType.DATE_RANGE:
        time_bytes = _pack_time(
            schedule.start_hour,
            schedule.start_minute,
            schedule.end_hour,
            schedule.end_minute,
        )
        date_bytes = _pack_date(
            schedule.start_month,
            schedule.start_day,
            schedule.start_year,
            schedule.end_month,
            schedule.end_day,
            schedule.end_year,
        )
        return time_bytes + date_bytes
    elif schedule.schedule_type == ScheduleType.WEEKLY:
        time_bytes = _pack_time(
            schedule.start_hour,
            schedule.start_minute,
            schedule.end_hour,
            schedule.end_minute,
        )
        days = schedule.days_of_week if schedule.days_of_week else DayOfWeek(0)
        day_byte = int(days) | 0x80  # bit 7 is always set for access codes
        return time_bytes + bytes([day_byte])
    else:
        # AllDay, OneTimeUnlimited, OneTime24Hour have no schedule data
        return b""


def build_access_code_message(
    slot: int,
    code: str,
    name: str,
    schedule: AccessCodeSchedule,
    enabled: bool = True,
) -> str:
    """Build a complete Base64-encoded TLV8 message for creating/editing an access code.

    :param slot: Slot index on the lock (0-255)
    :type slot: ``int``
    :param code: Numeric access code (4-8 digits)
    :type code: ``str``
    :param name: Friendly name for the access code
    :type name: ``str``
    :param schedule: Schedule configuration
    :type schedule: ``AccessCodeSchedule``
    :param enabled: Whether the code is enabled
    :type enabled: ``bool``
    :raises ValueError: If parameters are invalid
    :rtype: ``str``
    """
    if not 0 <= slot <= 255:
        raise ValueError(f"Slot must be 0-255, got {slot}")
    if not code or not code.isdigit():
        raise ValueError("Access code must be a non-empty numeric string")
    if not 4 <= len(code) <= 8:
        raise ValueError(f"Access code must be 4-8 digits, got {len(code)}")
    if not name:
        raise ValueError("Name cannot be empty")

    # Build access code TLV data
    enabled_flag = 1 if enabled else 0
    status_byte = (schedule.schedule_type << 4) | enabled_flag
    bcd_bytes = encode_bcd(code)
    access_code_data = bytes([slot, status_byte]) + bcd_bytes

    # Build message from TLV records
    message = _build_tlv8_record(_Tlv8SubType.ACCESS_CODE, access_code_data)

    # Add schedule TLV if needed
    schedule_data = _encode_schedule(schedule)
    if schedule_data:
        message += _build_tlv8_record(_Tlv8SubType.SCHEDULE, schedule_data)

    # Add friendly name TLV
    name_bytes = name.encode("utf-8")
    message += _build_tlv8_record(_Tlv8SubType.FRIENDLY_NAME, name_bytes)

    return base64.b64encode(message).decode("ascii")


def build_delete_message(slot: int) -> str:
    """Build a Base64-encoded message for deleting a single access code.

    :param slot: Slot index of the access code to delete (0-255)
    :type slot: ``int``
    :raises ValueError: If slot is out of range
    :rtype: ``str``
    """
    if not 0 <= slot <= 255:
        raise ValueError(f"Slot must be 0-255, got {slot}")
    data = bytes([_TxCommand.DELETE_DEVICE_ACCESS, slot])
    return base64.b64encode(data).decode("ascii")


def build_delete_all_message() -> str:
    """Build a Base64-encoded message for deleting all access codes.

    :rtype: ``str``
    """
    data = bytes([_TxCommand.DELETE_ALL_ACCESS_CODES])
    return base64.b64encode(data).decode("ascii")


class MqttCommandType(IntEnum):
    """MQTT command types from lock firmware responses.

    These are the command identifiers echoed by the lock firmware in the
    lockmqresponse field of subscription events. They differ from the BLE
    TxCommand values used in the TLV8 message encoding.
    """

    SET_ACCESS_CODE = 0x11
    EDIT_ACCESS_CODE = 0x15
    DELETE_ACCESS_CODE = 0x19


@dataclass(frozen=True)
class LockMqResponse:
    """Parsed lock MQTT response from subscription events.

    Contains the lock's response after processing an access code command,
    delivered via the lockmqresponse field of the onManageDevice subscription.

    :param crc: CRC/hash identifier from lock firmware
    :type crc: ``str``
    :param command: MQTT command type that was executed
    :type command: ``MqttCommandType``
    :param slot_index: Lock-assigned slot index (only present for set commands)
    :type slot_index: ``int | None``
    :param schedule_type: Schedule type echoed back (only present for set commands)
    :type schedule_type: ``int | None``
    :param enabled: Enabled flag echoed back (only present for set commands)
    :type enabled: ``bool | None``
    """

    crc: str
    command: MqttCommandType
    slot_index: int | None = None
    schedule_type: int | None = None
    enabled: bool | None = None


def parse_lock_mq_response(raw: str) -> LockMqResponse:
    """Parse a lockmqresponse string from a subscription event.

    The lockmqresponse field comes from the onManageDevice GraphQL
    subscription and contains the lock firmware's response after
    processing an access code command.

    Format: ``<crc>,<status_hex>,<command_hex>``

    :param raw: Raw lockmqresponse string (e.g., ``'37659173,03010A,11'``)
    :type raw: ``str``
    :raises ValueError: If the string cannot be parsed
    :rtype: ``LockMqResponse``
    """
    if not raw or not isinstance(raw, str):
        raise ValueError("lockmqresponse must be a non-empty string")

    parts = raw.split(",")
    if len(parts) != 3:
        raise ValueError(f"lockmqresponse must have 3 comma-separated fields, got {len(parts)}")

    crc = parts[0]
    status_hex = parts[1]
    command_hex = parts[2]

    if not crc:
        raise ValueError("CRC field cannot be empty")
    if not command_hex:
        raise ValueError("Command field cannot be empty")

    try:
        command = MqttCommandType(int(command_hex, 16))
    except ValueError as err:
        raise ValueError(f"Unknown MQTT command type: 0x{command_hex}") from err

    slot_index: int | None = None
    schedule_type: int | None = None
    enabled: bool | None = None

    if status_hex:
        try:
            status_bytes = bytes.fromhex(status_hex)
        except ValueError as err:
            raise ValueError(f"Invalid hex in status field: {status_hex!r}") from err
        if len(status_bytes) >= 3:
            schedule_type = status_bytes[0]
            enabled = status_bytes[1] == 1
            slot_index = status_bytes[2]
        elif len(status_bytes) >= 1:
            # Partial status - just capture what's available
            schedule_type = status_bytes[0]
            if len(status_bytes) >= 2:
                enabled = status_bytes[1] == 1

    return LockMqResponse(
        crc=crc,
        command=command,
        slot_index=slot_index,
        schedule_type=schedule_type,
        enabled=enabled,
    )


@dataclass(frozen=True)
class SingleAccessCodeCrc:
    """Parsed singleAccessCodeCrc from subscription events.

    The singleAccessCodeCrc field from the onManageDevice subscription
    contains a CRC token with an embedded slot index. The Kwikset app
    extracts the slot index from hex positions [4:6] to identify which
    access code slot the lock assigned.

    :param raw: Original unmodified CRC string
    :type raw: ``str``
    :param slot_index: Lock-assigned slot index extracted from positions [4:6]
    :type slot_index: ``int``
    :param crc_prefix: First 4 characters of the CRC string
    :type crc_prefix: ``str``
    :param crc_token: Full CRC token (positions [6:] onward)
    :type crc_token: ``str``
    """

    raw: str
    slot_index: int
    crc_prefix: str
    crc_token: str


def parse_single_access_code_crc(raw: str) -> SingleAccessCodeCrc:
    """Parse a singleAccessCodeCrc string from a subscription event.

    The singleAccessCodeCrc field comes from the onManageDevice GraphQL
    subscription and contains a CRC token with an embedded slot index.
    The slot index is encoded as a 2-character hex string at positions [4:6].

    This matches the extraction logic in the Kwikset Android APK
    (ds/w.java): ``Integer.parseInt(str.substring(4, 6), 16)``

    :param raw: Raw singleAccessCodeCrc string (minimum 6 characters)
    :type raw: ``str``
    :raises ValueError: If the string cannot be parsed
    :rtype: ``SingleAccessCodeCrc``
    """
    if not raw or not isinstance(raw, str):
        raise ValueError("singleAccessCodeCrc must be a non-empty string")

    if len(raw) < 6:
        raise ValueError(f"singleAccessCodeCrc must be at least 6 characters, got {len(raw)}")

    slot_hex = raw[4:6]
    try:
        slot_index = int(slot_hex, 16)
    except ValueError as err:
        raise ValueError(f"Invalid hex in slot index at positions [4:6]: {slot_hex!r}") from err

    return SingleAccessCodeCrc(
        raw=raw,
        slot_index=slot_index,
        crc_prefix=raw[:4],
        crc_token=raw[6:],
    )
