#!/usr/bin/env python3
"""Forward NFC card UIDs from the BAA BAA USB reader to the POS scan API.

The default HID backend supports the vendor reader used by the working prototype
(VID 0x0483, PID 0x4343). Use --backend pcsc for generic PC/SC readers.
"""
from __future__ import annotations

import argparse
import importlib
import json
import math
import sys
import time
from typing import Any, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

VID = 0x0483
PID = 0x4343

INIT_CMD = bytes([0x55, 0x00, 0x51, 0x00, 0x01, 0x01, 0xFE, 0x01]) + bytes(56)
POLL_CMD = bytes([0x55, 0x00, 0x69, 0x00, 0x00, 0xFF, 0x00]) + bytes(57)


class BridgeError(RuntimeError):
    """A selected reader backend cannot be started."""


def positive_float(value: str) -> float:
    try:
        number = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("must be a number") from exc
    if not math.isfinite(number) or number <= 0:
        raise argparse.ArgumentTypeError("must be greater than zero")
    return number


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", required=True, help="POS scan endpoint, e.g. http://localhost/scan.php")
    parser.add_argument("--backend", choices=("hid", "pcsc", "auto"), default="hid",
                        help="Reader protocol: prototype USB HID (default), PC/SC, or try HID then PC/SC")
    parser.add_argument("--reader", help="Optional reader-name substring for the PC/SC backend")
    parser.add_argument("--vid", type=lambda value: int(value, 0), default=VID,
                        help="USB vendor ID for HID mode (default: 0x0483)")
    parser.add_argument("--pid", type=lambda value: int(value, 0), default=PID,
                        help="USB product ID for HID mode (default: 0x4343)")
    parser.add_argument("--poll", "--interval", dest="poll", type=positive_float, default=0.3,
                        help="Pause between polls in seconds (default: 0.3)")
    parser.add_argument("--timeout", type=positive_float, default=4.0,
                        help="HTTP request timeout in seconds (default: 4)")
    return parser.parse_args()


def _parse_hid_response(raw: Any) -> Tuple[Optional[str], bool]:
    """Return (UID, valid-response); a valid response with no UID means removal."""
    if not raw:
        return None, False

    data = bytes(raw)
    # Some hidapi builds include the zero-valued report ID in the returned data.
    if len(data) > 1 and data[0] == 0x00 and data[1] == 0x55:
        data = data[1:]

    if len(data) < 18 or data[:4] != bytes([0x55, 0x00, 0x69, 0x00]):
        return None, False

    # The vendor response has a separate UID length at byte 17. Byte 4 is a
    # response field and remains 0x13 for both four-byte and seven-byte UIDs.
    uid_length = data[17]
    if uid_length == 0:
        return None, True
    if uid_length > 10 or len(data) < 7 + uid_length:
        return None, False

    uid = data[7:7 + uid_length].hex().upper()
    return uid, True


def parse_uid(raw: Any) -> Optional[str]:
    """Parse a card UID from one vendor HID response, or return None."""
    uid, valid = _parse_hid_response(raw)
    return uid if valid else None


def _decode_json(raw: bytes) -> Optional[dict]:
    try:
        result = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None
    return result if isinstance(result, dict) else None


def post_uid(url: str, uid: str, timeout: float) -> bool:
    body = json.dumps({"uid": uid}).encode("utf-8")
    request = Request(
        url,
        data=body,
        headers={"Content-Type": "application/json", "Accept": "application/json"},
        method="POST",
    )
    shown_uid = uid
    try:
        with urlopen(request, timeout=timeout) as response:
            result = _decode_json(response.read())
    except HTTPError as exc:
        result = _decode_json(exc.read())
        message = result.get("message") if result else "HTTP {}".format(exc.code)
        print("POS rejected card {}: {}".format(shown_uid, message))
        return False
    except (URLError, TimeoutError, OSError) as exc:
        print("Could not reach POS at {} for card {}: {}".format(url, shown_uid, exc))
        return False

    if not result:
        print("POS returned an invalid JSON response for card {}".format(shown_uid))
        return False
    if result.get("captured"):
        print("UID captured by POS for card registration: {}".format(shown_uid))
        return True
    if not result.get("ok"):
        print("POS response for card {}: {}".format(shown_uid, result.get("message", "Unknown result")))
        return False

    member = result.get("member") or {}
    name = member.get("name") or member.get("member_no") or "Member"
    print("Member linked: {} ({} points)".format(name, member.get("points", 0)))
    return True


class HIDReader:
    """Adapter for both common Python hidapi object styles."""

    def __init__(self, vid: int, pid: int):
        self.vid = vid
        self.pid = pid
        self.module = None
        self.handle = None
        self.product = "USB HID NFC reader"

    def connect(self) -> None:
        self.close()
        try:
            self.module = importlib.import_module("hid")
        except ImportError as exc:
            raise BridgeError("HID support is missing. Install it with: py -m pip install hidapi") from exc

        if not hasattr(self.module, "device") and not hasattr(self.module, "Device"):
            raise BridgeError("The imported 'hid' module is not hidapi. Run: py -m pip uninstall hid; py -m pip install hidapi")

        try:
            enumerate_devices = getattr(self.module, "enumerate", None)
            if callable(enumerate_devices):
                try:
                    devices = list(enumerate_devices(self.vid, self.pid))
                except TypeError:
                    devices = list(enumerate_devices())
                matches = [
                    item for item in devices
                    if int(item.get("vendor_id", -1)) == self.vid and int(item.get("product_id", -1)) == self.pid
                ]
            else:
                # Some older hidapi wrappers only support opening by VID/PID.
                matches = [{"vendor_id": self.vid, "product_id": self.pid}]
        except Exception as exc:
            raise BridgeError("Could not enumerate HID devices: {}".format(exc)) from exc

        if not matches:
            raise BridgeError("No HID reader found with VID 0x{:04X} and PID 0x{:04X}.".format(self.vid, self.pid))

        info = matches[0]
        self.product = str(info.get("product_string") or "USB HID NFC reader")
        try:
            if hasattr(self.module, "device"):
                handle = self.module.device()
                handle.open(self.vid, self.pid)
                if hasattr(handle, "set_nonblocking"):
                    handle.set_nonblocking(False)
            else:
                path = info.get("path")
                if path:
                    handle = self.module.Device(path=path)
                else:
                    handle = self.module.Device(vid=self.vid, pid=self.pid)
            self.handle = handle
            self.send(INIT_CMD)
            # Consume the initialization acknowledgement if the reader sends one.
            self.read(1000)
        except Exception as exc:
            self.close()
            raise BridgeError("Could not open or initialize the HID reader: {}".format(exc)) from exc

    def send(self, payload: bytes) -> None:
        if self.handle is None:
            raise BridgeError("HID reader is not connected.")
        # hidapi expects the report ID as byte zero; this reader uses report ID 0.
        written = self.handle.write(bytes([0x00]) + payload)
        if written is not None and written < 0:
            raise BridgeError("The HID reader rejected a command.")

    def read(self, timeout_ms: int) -> bytes:
        if self.handle is None:
            raise BridgeError("HID reader is not connected.")
        try:
            response = self.handle.read(64, timeout=timeout_ms)
        except TypeError:
            try:
                response = self.handle.read(64, timeout_ms=timeout_ms)
            except TypeError:
                # Last fallback for older wrappers that expose no timeout arg.
                response = self.handle.read(64)
        return bytes(response or [])

    def poll(self, timeout_ms: int) -> bytes:
        self.send(POLL_CMD)
        return self.read(timeout_ms)

    def close(self) -> None:
        handle = self.handle
        self.handle = None
        if handle is not None:
            try:
                handle.close()
            except Exception:
                pass


def run_hid(args: argparse.Namespace) -> int:
    reader = HIDReader(args.vid, args.pid)
    reader.connect()
    print("Opened HID reader: {}".format(reader.product))
    print("Forwarding scans to {} (Ctrl+C to stop)".format(args.url))

    previous_uid = None
    timeout_ms = max(50, int(args.poll * 1000))
    try:
        while True:
            try:
                raw = reader.poll(timeout_ms)
                uid, valid = _parse_hid_response(raw)
                if valid:
                    if uid is None:
                        previous_uid = None
                    elif uid != previous_uid:
                        print("Card detected UID: {}".format(uid))
                        post_uid(args.url, uid, args.timeout)
                        previous_uid = uid
                time.sleep(args.poll)
            except KeyboardInterrupt:
                raise
            except Exception as exc:
                print("HID reader communication error: {}".format(exc), file=sys.stderr)
                reader.close()
                previous_uid = None
                while True:
                    time.sleep(1.0)
                    try:
                        reader.connect()
                        print("HID reader reconnected: {}".format(reader.product))
                        break
                    except BridgeError as reconnect_error:
                        print("Waiting for HID reader: {}".format(reconnect_error), file=sys.stderr)
    finally:
        reader.close()


def run_pcsc(args: argparse.Namespace) -> int:
    try:
        from smartcard.System import readers
    except ImportError as exc:
        raise BridgeError("PC/SC support is missing. Install it with: py -m pip install pyscard") from exc

    try:
        available = list(readers())
    except Exception as exc:
        raise BridgeError("Could not enumerate PC/SC readers: {}".format(exc)) from exc

    if args.reader:
        available = [reader for reader in available if args.reader.casefold() in str(reader).casefold()]
    if not available:
        raise BridgeError("No matching PC/SC reader found. Check the Windows Smart Card service and reader connection.")

    reader = available[0]
    print("Opened PC/SC reader: {}".format(reader))
    print("Forwarding scans to {} (Ctrl+C to stop)".format(args.url))
    previous_uid = None

    while True:
        connection = reader.createConnection()
        try:
            connection.connect()
        except Exception:
            previous_uid = None
            time.sleep(args.poll)
            continue

        try:
            data, sw1, sw2 = connection.transmit([0xFF, 0xCA, 0x00, 0x00, 0x00])
            if sw1 == 0x90 and sw2 == 0x00 and data:
                uid = "".join("{:02X}".format(byte) for byte in data)
                if uid != previous_uid:
                    print("Card detected UID: {}".format(uid))
                    post_uid(args.url, uid, args.timeout)
                    previous_uid = uid
            else:
                previous_uid = None
        except Exception as exc:
            print("PC/SC reader communication error: {}".format(exc), file=sys.stderr)
            previous_uid = None
        finally:
            try:
                connection.disconnect()
            except Exception:
                pass
        time.sleep(args.poll)


def main() -> int:
    args = parse_args()
    try:
        if args.backend == "pcsc":
            run_pcsc(args)
            return 0
        if args.backend == "hid":
            run_hid(args)
            return 0

        try:
            run_hid(args)
        except BridgeError as exc:
            print("HID backend unavailable: {}".format(exc), file=sys.stderr)
            print("Trying the PC/SC backend...", file=sys.stderr)
            run_pcsc(args)
        return 0
    except KeyboardInterrupt:
        print("\nNFC bridge stopped.")
        return 0
    except BridgeError as exc:
        print(str(exc), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

