<?php
/**
 * ZKTeco biometric device connector (TCP socket, ZK protocol).
 * Pulls attendance log (sign-in/sign-out) for dashboard and optional login audit.
 * Requires PHP sockets extension. Device must be on same network, port typically 4370.
 */

class ZKTecoConnector
{
    /** @var resource|null */
    private $socket;

    /** @var int */
    private $sessionId = 0;

    /** @var int */
    private $replyId = 0;

    /** @var string */
    private $lastError = '';

    /** ZK protocol constants (common variant) */
    const CMD_CONNECT       = 1000;
    const CMD_EXIT          = 1001;
    const CMD_ENABLE_DEVICE = 1002;
    const CMD_ATT_LOG_RRQ   = 1102;
    const CMD_PREPARE_DATA  = 1500;
    const CMD_DATA          = 1501;
    const CMD_ACK           = 2002;
    const CMD_ACK_OK        = 2000;

    /** Packet header (many ZKTeco devices) */
    const HEADER = "\x50\x50\x82\x7D";

    /** @var int */
    private $timeoutSec = 5;

    /**
     * Connect to device.
     * @param string $ip
     * @param int $port Usually 4370
     * @return bool
     */
    public function connect($ip, $port = 4370)
    {
        $this->lastError = '';
        $this->socket = @fsockopen($ip, (int) $port, $errno, $errstr, $this->timeoutSec);
        if (!$this->socket) {
            $this->lastError = "Connect failed: $errstr ($errno)";
            return false;
        }
        stream_set_timeout($this->socket, $this->timeoutSec);

        if (!$this->connectProtocol()) {
            $this->disconnect();
            return false;
        }
        return true;
    }

    /**
     * Send connect command and read session.
     */
    private function connectProtocol()
    {
        $payload = pack('v', 0); // protocol version / type
        $this->sendPacket(self::CMD_CONNECT, $payload);
        $data = $this->readPacket();
        if ($data === null) {
            $this->lastError = 'No reply to connect';
            return false;
        }
        $cmd = unpack('v', substr($data, 0, 2))[1];
        if ($cmd === self::CMD_CONNECT && strlen($data) >= 4) {
            $this->sessionId = unpack('v', substr($data, 2, 2))[1];
            return true;
        }
        $this->lastError = 'Connect reply invalid';
        return false;
    }

    /**
     * Send packet: header + length + command + session + reply + payload (little-endian).
     */
    private function sendPacket($command, $payload = '')
    {
        $length = 8 + strlen($payload);
        $packet = self::HEADER
            . pack('V', $length)
            . pack('v', $command)
            . pack('v', $this->sessionId)
            . pack('v', $this->replyId++)
            . $payload;
        @fwrite($this->socket, $packet);
    }

    /**
     * Read one reply packet. Returns payload only or null on error/timeout.
     */
    private function readPacket()
    {
        $header = @fread($this->socket, 4);
        if ($header === false || strlen($header) < 4) {
            return null;
        }
        if ($header !== self::HEADER) {
            $this->lastError = 'Invalid packet header';
            return null;
        }
        $lenBuf = @fread($this->socket, 4);
        if ($lenBuf === false || strlen($lenBuf) < 4) {
            return null;
        }
        $length = unpack('V', $lenBuf)[1];
        if ($length < 8 || $length > 65535) {
            return null;
        }
        $toRead = $length - 8;
        $payload = '';
        while ($toRead > 0) {
            $chunk = @fread($this->socket, $toRead);
            if ($chunk === false || $chunk === '') {
                return null;
            }
            $payload .= $chunk;
            $toRead -= strlen($chunk);
        }
        return $payload;
    }

    /**
     * Get attendance log from device.
     * Returns array of [ 'zk_user_id' => string, 'punch_time' => 'Y-m-d H:i:s', 'punch_state' => 'IN'|'OUT'|'CHECK', 'zk_badge_no' => string ].
     */
    public function getAttendanceLog()
    {
        if (!$this->socket) {
            $this->lastError = 'Not connected';
            return [];
        }
        $this->sendPacket(self::CMD_ATT_LOG_RRQ, '');
        $records = [];
        $seen = [];
        while (true) {
            $data = $this->readPacket();
            if ($data === null || strlen($data) < 2) {
                break;
            }
            $cmd = unpack('v', substr($data, 0, 2))[1];
            if ($cmd === self::CMD_ACK_OK || $cmd === self::CMD_ACK) {
                break;
            }
            if ($cmd === self::CMD_PREPARE_DATA && strlen($data) >= 8) {
                $size = unpack('V', substr($data, 4, 4))[1];
                $this->sendPacket(self::CMD_ACK, pack('v', $this->replyId - 1));
                continue;
            }
            if ($cmd === self::CMD_DATA && strlen($data) >= 8) {
                $payload = substr($data, 8);
                $chunkRecords = $this->parseAttendanceChunk($payload);
                foreach ($chunkRecords as $r) {
                    $key = $r['zk_user_id'] . '@' . $r['punch_time'];
                    if (!isset($seen[$key])) {
                        $seen[$key] = true;
                        $records[] = $r;
                    }
                }
                $this->sendPacket(self::CMD_ACK, pack('v', $this->replyId - 1));
                continue;
            }
            break;
        }
        usort($records, function ($a, $b) {
            return strcmp($a['punch_time'], $b['punch_time']);
        });
        return $records;
    }

    /**
     * Parse attendance chunk. Common record size 40 bytes: user_id (2), state (1), reserved, timestamp (4), etc.
     */
    private function parseAttendanceChunk($payload)
    {
        $records = [];
        $recordSize = 40; // typical ZKTeco att log record size
        $n = strlen($payload);
        for ($i = 0; $i + $recordSize <= $n; $i += $recordSize) {
            $chunk = substr($payload, $i, $recordSize);
            $userId = unpack('v', substr($chunk, 0, 2))[1];
            $state = ord(substr($chunk, 2, 1));
            $timestamp = 0;
            if (strlen($chunk) >= 12) {
                $timestamp = unpack('V', substr($chunk, 8, 4))[1];
            }
            if ($timestamp <= 0) {
                continue;
            }
            $punchState = 'CHECK';
            if ($state === 0 || $state === 1) {
                $punchState = 'IN';
            } elseif ($state === 2 || $state === 4) {
                $punchState = 'OUT';
            }
            $records[] = [
                'zk_user_id'  => (string) $userId,
                'zk_badge_no' => (string) $userId,
                'punch_time'  => date('Y-m-d H:i:s', $timestamp),
                'punch_state' => $punchState,
            ];
        }
        return $records;
    }

    public function disconnect()
    {
        if ($this->socket) {
            $this->sendPacket(self::CMD_EXIT, '');
            @fclose($this->socket);
            $this->socket = null;
        }
    }

    public function getLastError()
    {
        return $this->lastError;
    }

    public function setTimeout($seconds)
    {
        $this->timeoutSec = max(1, (int) $seconds);
    }

    public function __destruct()
    {
        $this->disconnect();
    }
}
