"""
Cliente protocolo Anviz (TCP puerto 5010).
Basado en coyotevz/anviz y documentación del protocolo.
"""

from __future__ import annotations

import socket
import struct
from dataclasses import dataclass
from datetime import datetime
from typing import Iterator

STX = 0xA5
ACK_SUM = 0x80
RET_SUCCESS = 0x00
RET_FAIL = 0x01
RET_PASSWORD = 0x07  # contraseña de comunicación incorrecta (varios firmwares)

# Segundos desde 2000-01-02 00:00:00 UTC (convención Anviz)
SSEC = 946782000.0

RET_MESSAGES = {
    RET_FAIL: "operación rechazada por el reloj",
    0x04: "memoria de usuarios llena",
    0x05: "sin usuarios",
    0x06: "usuario no existe",
    0x08: "tiempo de espera agotado",
    RET_PASSWORD: "contraseña de comunicación incorrecta",
    0x0A: "usuario ya existe",
    0x0B: "huella ya registrada",
    0x0F: "dispositivo bloqueado",
}

CMD_GET_RECORD_INFO = 0x3C
CMD_DOWNLOAD_RECORDS = 0x40
CMD_GET_DATETIME = 0x38

_CRC_TABLE = (
    0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF, 0x8C48, 0x9DC1,
    0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7, 0x1081, 0x0108, 0x3393, 0x221A,
    0x56A5, 0x472C, 0x75B7, 0x643E, 0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64,
    0xF9FF, 0xE876, 0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD,
    0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5, 0x3183, 0x200A,
    0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C, 0xBDCB, 0xAC42, 0x9ED9, 0x8F50,
    0xFBEF, 0xEA66, 0xD8FD, 0xC974, 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9,
    0x2732, 0x36BB, 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3,
    0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A, 0xDECD, 0xCF44,
    0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72, 0x6306, 0x728F, 0x4014, 0x519D,
    0x2522, 0x34AB, 0x0630, 0x17B9, 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3,
    0x8A78, 0x9BF1, 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738,
    0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70, 0x8408, 0x9581,
    0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7, 0x0840, 0x19C9, 0x2B52, 0x3ADB,
    0x4E64, 0x5FED, 0x6D76, 0x7CFF, 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324,
    0xF1BF, 0xE036, 0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E,
    0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5, 0x2942, 0x38CB,
    0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD, 0xB58B, 0xA402, 0x9699, 0x8710,
    0xF3AF, 0xE226, 0xD0BD, 0xC134, 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E,
    0x5CF5, 0x4D7C, 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3,
    0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB, 0xD68D, 0xC704,
    0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232, 0x5AC5, 0x4B4C, 0x79D7, 0x685E,
    0x1CE1, 0x0D68, 0x3FF3, 0x2E7A, 0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3,
    0x8238, 0x93B1, 0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9,
    0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330, 0x7BC7, 0x6A4E,
    0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78,
)


def crc16(data: bytes | bytearray) -> bytes:
    crc = 0xFFFF
    for b in data:
        crc ^= b
        crc = (crc >> 8) ^ _CRC_TABLE[crc & 0xFF]
    return struct.pack("<H", crc)


def _left_fill(b: bytearray, n: int) -> bytes:
    return (b"\x00" * n + bytes(b))[-n:]


@dataclass
class AnvizRecord:
    user_code: int
    timestamp: datetime
    backup_type: int  # 0=huella1, 1=huella2, 2=password, 3=tarjeta
    record_type: int  # 0=entrada, 1=salida
    work_type: int


@dataclass
class RecordsInfo:
    all_records: int
    new_records: int


class AnvizError(Exception):
    pass


def encode_comm_password(password: str | int | None) -> bytes | None:
    """
    Codifica la contraseña de comunicación Anviz (Comm.PW / PW Setting).
    Formato: 3 bytes según protocolo SET_INFO (valor numérico, ej. 12345 → 00 30 39).
    """
    if password is None or password == "" or password == 0:
        return None
    pwd = str(password).strip()
    if not pwd.isdigit():
        raise ValueError("La contraseña de comunicación debe ser numérica (máx. 5 dígitos)")
    if len(pwd) > 5:
        raise ValueError("La contraseña de comunicación admite máximo 5 dígitos")
    value = int(pwd)
    return value.to_bytes(3, byteorder="big")


def encode_comm_password_bcd(password: str) -> bytes:
    """Formato alternativo BCD por dígito (algunos firmwares antiguos)."""
    pwd = str(password).strip()
    pass_len_bin = format(len(pwd), "04b")
    pass_bin = "".join(format(int(d), "04b") for d in pwd)
    first4 = pass_bin[:4]
    byte0 = int(pass_len_bin + first4, 2)
    byte1 = int(pass_bin[4:12], 2)
    byte2 = int(pass_bin[12:20], 2) if len(pass_bin) >= 20 else 0
    return bytes([byte0, byte1, byte2])


class AnvizDevice:
    def __init__(
        self,
        device_id: int,
        host: str,
        port: int = 5010,
        timeout: float = 10.0,
        comm_password: str | int | None = None,
    ):
        self.device_id = device_id
        self.host = host
        self.port = port
        self.timeout = timeout
        self.comm_password = comm_password
        self._comm_password_bytes: bytes | None = encode_comm_password(comm_password)
        self._sock: socket.socket | None = None

    def connect(self) -> None:
        self.close()
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(self.timeout)
        sock.connect((self.host, self.port))
        self._sock = sock

    def close(self) -> None:
        if self._sock:
            try:
                self._sock.close()
            except OSError:
                pass
            self._sock = None

    def __enter__(self) -> AnvizDevice:
        self.connect()
        return self

    def __exit__(self, *_) -> None:
        self.close()

    def _build_request(self, cmd: int, data: bytes = b"") -> bytes:
        req = bytearray([STX])
        req.extend(struct.pack(">L", self.device_id))
        req.append(cmd)
        req.extend(struct.pack(">H", len(data)))
        if data:
            req.extend(data)
        req.extend(crc16(req))
        return bytes(req)

    def _recv_exact(self, n: int) -> bytes:
        assert self._sock is not None
        buf = bytearray()
        while len(buf) < n:
            chunk = self._sock.recv(n - len(buf))
            if not chunk:
                raise AnvizError("Conexión cerrada por el reloj")
            buf.extend(chunk)
        return bytes(buf)

    def _prepend_password(self, data: bytes) -> bytes:
        if not self._comm_password_bytes:
            return data
        return self._comm_password_bytes + data

    def _check_response(self, cmd: int, resp: bytes) -> None:
        if len(resp) < 8 or resp[0] != STX:
            raise AnvizError("Respuesta inválida del reloj")
        dev_id, ack, ret = struct.unpack(">xLcc", resp[:8])
        ret_code = ord(ret)
        if dev_id != self.device_id:
            raise AnvizError(
                f"ID de dispositivo incorrecto (configurado {self.device_id}, "
                f"respondió {dev_id}). Revise device_id en relojes.json"
            )
        if ack != bytes([cmd + ACK_SUM]):
            raise AnvizError(f"ACK incorrecto para comando 0x{cmd:02X}")
        if ret_code != RET_SUCCESS:
            hint = RET_MESSAGES.get(ret_code, f"código 0x{ret_code:02X}")
            if ret_code in (RET_FAIL, RET_PASSWORD) and self._comm_password_bytes:
                hint += " — verifique comm_password (PW Setting del reloj)"
            elif ret_code in (RET_FAIL, RET_PASSWORD) and not self._comm_password_bytes:
                hint += " — el reloj puede tener Comm.PW activado; configure comm_password"
            raise AnvizError(f"El reloj rechazó el comando: {hint}")

    def _get_response(self, cmd: int, args: list[int] | None = None) -> bytes:
        if self._sock is None:
            raise AnvizError("No hay conexión con el reloj")
        data = bytes(args) if args else b""
        data = self._prepend_password(data)
        req = self._build_request(cmd, data)
        self._sock.send(req)

        header = self._recv_exact(8)
        self._check_response(cmd, header)

        rlen = self._recv_exact(2)
        data_len = struct.unpack(">H", rlen)[0]
        payload = self._recv_exact(data_len) if data_len else b""
        crc = self._recv_exact(2)

        full = header + rlen + payload
        if crc16(full) != crc:
            raise AnvizError("Error de checksum en respuesta")
        return payload

    def get_datetime(self) -> datetime:
        data = self._get_response(CMD_GET_DATETIME)
        y, m, d, h, mi, s = struct.unpack("BBBBBB", data[:6])
        return datetime(2000 + y, m, d, h, mi, s)

    def get_record_info(self) -> RecordsInfo:
        data = self._get_response(CMD_GET_RECORD_INFO)
        it = iter(data)
        # saltar users, fp, passwd, card (3 bytes cada uno)
        for _ in range(4):
            next(it)
            next(it)
            next(it)
        all_records = sum(struct.unpack(">BH", bytes([next(it), next(it), next(it)])))
        new_records = sum(struct.unpack(">BH", bytes([next(it), next(it), next(it)])))
        return RecordsInfo(all_records=all_records, new_records=new_records)

    @staticmethod
    def _parse_record(raw: bytearray) -> AnvizRecord:
        uid = struct.unpack(">Q", _left_fill(raw[:5], 8))[0]
        sec = struct.unpack(">I", bytes(raw[5:9]))[0]
        bkp = raw[9]
        rtype = raw[10]
        wtype = struct.unpack(">I", _left_fill(raw[11:14], 4))[0]
        ts = datetime.fromtimestamp(SSEC + sec)
        return AnvizRecord(uid, ts, bkp, rtype, wtype)

    @staticmethod
    def _parse_records_block(data: bytes) -> list[AnvizRecord]:
        buf = bytearray(data)
        if not buf:
            return []
        count = buf.pop(0)
        records: list[AnvizRecord] = []
        offset = 0
        while offset + 14 <= len(buf) and len(records) < count:
            chunk = buf[offset : offset + 14]
            records.append(AnvizDevice._parse_record(bytearray(chunk)))
            offset += 14
        return records

    def download_all_records(self) -> list[AnvizRecord]:
        info = self.get_record_info()
        total = info.all_records
        if total == 0:
            return []

        batch = min(25, total)
        all_records: list[AnvizRecord] = []
        data = self._get_response(CMD_DOWNLOAD_RECORDS, [1, batch])
        all_records.extend(self._parse_records_block(data))

        left = total - batch
        while left > 0:
            batch = min(25, left)
            data = self._get_response(CMD_DOWNLOAD_RECORDS, [0, batch])
            all_records.extend(self._parse_records_block(data))
            left -= batch

        return all_records

    def _ping_once(self) -> dict:
        dt = self.get_datetime()
        info = self.get_record_info()
        return {
            "connected": True,
            "device_time": dt.isoformat(sep=" "),
            "all_records": info.all_records,
            "new_records": info.new_records,
        }

    def ping(self) -> dict:
        """Verifica conectividad y devuelve estado del reloj."""
        if not self.comm_password:
            return self._ping_once()

        pwd = str(self.comm_password).strip()
        encodings = [
            encode_comm_password(pwd),
            encode_comm_password_bcd(pwd),
        ]
        last_error: AnvizError | None = None
        for encoded in encodings:
            if not encoded:
                continue
            self._comm_password_bytes = encoded
            try:
                return self._ping_once()
            except AnvizError as exc:
                last_error = exc
        raise last_error or AnvizError("No se pudo autenticar con el reloj")


BACKUP_LABELS = {0: "Huella 1", 1: "Huella 2", 2: "Contraseña", 3: "Tarjeta"}
TYPE_LABELS = {0: "Entrada", 1: "Salida"}
