<?php
declare(strict_types=1);

/**
 * Sofar Wechselrichter Modul (IP-Symcon)
 *
 * - LoggerNumber als String (kein Integer-Overflow).
 * - Negative Skalierungen erlaubt.
 * - Bit-Länge pro Register (16, 32, 64) auswählbar.
 * - Signed/Unsigned pro Register wählbar.
 * - Liest 32/64-Bit-Werte registerweise einzeln und setzt anschließend zusammen.
 * - Gelöschte Register-Variablen werden entfernt.
 * - Debug-Logs zeigen Raw-Response, um Slice-Position zu prüfen.
 */
class SofarWechselrichter extends IPSModule
{
    public function Create()
    {
        parent::Create();
        // Moduleigenschaften
        $this->RegisterPropertyString('IPAddress', '');
        $this->RegisterPropertyString('LoggerNumber', '0');        // als String
        $this->RegisterPropertyInteger('PollInterval', 60);
        $this->RegisterPropertyString('Registers', '[]');          // JSON-String

        // Timer für zyklische Abfragen (per RequestAction("Query"))
        $script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");';
        $this->RegisterTimer('QueryTimer', 0, $script);
    }

    public function ApplyChanges()
    {
        parent::ApplyChanges();
        // Timer-Intervall (ms)
        $intervalSec = $this->ReadPropertyInteger('PollInterval');
        $intervalMs  = ($intervalSec > 0) ? $intervalSec * 1000 : 0;
        $this->SetTimerInterval('QueryTimer', $intervalMs);

        // Aktuelle Registerliste
        $registers = json_decode($this->ReadPropertyString('Registers'), true);
        if (!is_array($registers)) {
            $registers = [];
        }

        // 1) Variablen anlegen (neu hinzugekommene Register)
        $position = 10;
        foreach ($registers as $entry) {
            $regNo = (int) $entry['RegisterNumber'];
            $label = trim($entry['Label']);
            if ($regNo < 0 || $label === '') {
                continue;
            }
            $ident = 'Reg' . $regNo;
            if (!$this->VariableExists($ident)) {
                $this->RegisterVariableFloat($ident, $label, '', $position);
            }
            $position += 10;
        }

        // 2) Variablen löschen (falls Register entfernt wurden)
        $validIdents = [];
        foreach ($registers as $entry) {
            $validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']);
        }
        $children = IPS_GetChildrenIDs($this->InstanceID);
        foreach ($children as $childID) {
            $obj = IPS_GetObject($childID);
            if ($obj['ObjectType'] !== 2) {
                continue;
            }
            $ident = $obj['ObjectIdent'];
            if (substr($ident, 0, 3) === 'Reg') {
                if (!in_array($ident, $validIdents)) {
                    IPS_DeleteVariable($childID);
                }
            }
        }
    }

    public function Destroy()
    {
        parent::Destroy();
    }

    /**
     * Wird aufgerufen durch IPS_RequestAction über den Timer
     */
    public function RequestAction($Ident, $Value)
    {
        switch ($Ident) {
            case 'Query':
                $this->Query();
                break;
            default:
                throw new Exception('Ungültiger Ident: ' . $Ident);
        }
    }

    /**
     * Zyklische Abfrage aller definierten Register
     */
    private function Query(): void
    {
        $this->LogMessage('Query invoked', KL_MESSAGE);

        $ip              = trim($this->ReadPropertyString('IPAddress'));
        $loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber'));

        // 1) Validierung IP
        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            $this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING);
            return;
        }
        // 2) Validierung LoggerNumber (Dezimal-String, > 0)
        if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) {
            $this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING);
            return;
        }

        // 3) Register-Liste einlesen
        $registers = json_decode($this->ReadPropertyString('Registers'), true);
        if (!is_array($registers) || count($registers) === 0) {
            // Keine Register definiert
            return;
        }

        // 4) Für jedes Register: einzeln auslesen, zusammensetzen, skalieren, speichern
        foreach ($registers as $entry) {
            $regNo      = (int)   $entry['RegisterNumber'];
            $label      = trim((string)$entry['Label']);
            $scale      = (string) $entry['ScalingFactor'];    // kann negativ sein
            $endian     = strtoupper(trim((string)$entry['Endian']));
            $bitLength  = (int)   $entry['BitLength'];         // 16, 32 oder 64
            $signedness = trim((string)$entry['Signedness']);  // "Signed" oder "Unsigned"
            $ident      = 'Reg' . $regNo;

            if ($regNo < 0 || $label === '' || !in_array($bitLength, [16, 32, 64])) {
                continue;
            }

            try {
                $numRegs = $bitLength / 16; // 1, 2 oder 4
                // Bytes registerweise einzeln abfragen und zusammenfügen:
                $dataBytes = '';
                for ($i = 0; $i < $numRegs; $i++) {
                    $chunk = $this->readSingleRegister($ip, $loggerNumberStr, $regNo + $i);
                    $dataBytes .= $chunk;
                }
                // Debug: raw combined response hex
                $combinedHex = strtoupper(bin2hex($dataBytes));
                $this->LogMessage("Raw data for Reg {$regNo} ({$bitLength}bit): {$combinedHex}", KL_MESSAGE);

                // Endian-Handling: falls LE, kehre gesamte Byte-Reihenfolge um
                if ($endian === 'LE') {
                    $combinedHex = $this->reverseByteOrder($combinedHex);
                    $this->LogMessage("After LE reverse: {$combinedHex}", KL_MESSAGE);
                }

                // Konvertiere Hex in Dezimal-String
                $rawDec = $this->hexToDecimal($combinedHex);

                // Bei "Signed" → Zwei-Komplement-Umrechnung
                if ($signedness === 'Signed') {
                    $half      = bcpow('2', (string)($bitLength - 1), 0); // 2^(bitLength-1)
                    $fullRange = bcpow('2', (string)$bitLength, 0);       // 2^bitLength
                    if (bccomp($rawDec, $half) >= 0) {
                        $rawDec = bcsub($rawDec, $fullRange, 0);
                    }
                    $this->LogMessage("Signed rawDec for Reg {$regNo}: {$rawDec}", KL_MESSAGE);
                }

                // Skaliere (bc*-Multiplikation, 4 Nachkommastellen)
                $valueStr = bcmul($rawDec, $scale, 4);
                SetValueFloat($this->GetIDForIdent($ident), (float)$valueStr);
                $this->LogMessage("Final value for Reg {$regNo}: {$valueStr}", KL_MESSAGE);
            } catch (Exception $e) {
                $this->LogMessage(
                    "Fehler Lesen Reg {$regNo} ({$bitLength}bit, {$signedness}): " . $e->getMessage(),
                    KL_WARNING
                );
            }
        }
    }

    /**
     * Liest genau ein Register (16 Bit) per Modbus-ähnlichem TCP (2 Bytes zurück).
     *
     * @param string $ip            Inverter-IP
     * @param string $serial_nr_str Logger-Seriennummer als Dezimal-String
     * @param int    $reg           Register-Adresse
     * @return string               2-Byte-Binär-String
     * @throws Exception            Bei Kommunikationsfehlern
     */
    private function readSingleRegister(string $ip, string $serial_nr_str, int $reg): string
    {
        // 1) Out_Frame ohne CRC aufbauen
        $oFrame = 'a5170010450000';

        // Dezimal-String → 8-stellige Hex
        $hexSN8 = $this->decStringToHex8($serial_nr_str);
        $hexSNbytes = [
            substr($hexSN8, 6, 2),
            substr($hexSN8, 4, 2),
            substr($hexSN8, 2, 2),
            substr($hexSN8, 0, 2),
        ];
        $oFrame .= implode('', $hexSNbytes);

        // Data-Field (16 Hex-Zeichen konstant)
        $oFrame .= '020000000000000000000000000000';

        // Business-Field: 01 03 + Start-Register + Anzahl Register (1)
        $startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT);
        $numHex   = str_pad(dechex(1),   4, '0', STR_PAD_LEFT);
        $oFrame  .= '0103' . $startHex . $numHex;

        // 2) CRC16-Modbus (letzte 6 Bytes)
        $crcInputHex = substr($oFrame, -12);
        $crcInputBin = hex2bin($crcInputHex);
        if ($crcInputBin === false) {
            throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}");
        }
        $crcValue      = $this->calculateCRC16Modbus($crcInputBin);
        $crcHex        = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT));
        $crcSwapped    = substr($crcHex, 2, 2) . substr($crcHex, 0, 2);
        $oFrameWithCRC = $oFrame . strtolower($crcSwapped);

        // 3) Summen-Checksum (alle Bytes ab Index 1) + 0x15
        $l    = strlen($oFrameWithCRC) / 2;
        $bArr = [];
        for ($i = 0; $i < $l; $i++) {
            $byteHex   = substr($oFrameWithCRC, 2 * $i, 2);
            $bArr[$i] = hexdec($byteHex);
        }
        $crcSum = 0;
        for ($i = 1; $i < $l; $i++) {
            $crcSum += $bArr[$i];
            $crcSum &= 0xFF;
        }
        $bArr[$l]   = $crcSum;
        $bArr[$l+1] = 0x15;

        $frameBin = '';
        foreach ($bArr as $b) {
            $frameBin .= chr($b);
        }

        // 4) TCP-Verbindung öffnen & Paket senden (Port fest 8899)
        $port = 8899;
        $fp   = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5);
        if (!$fp) {
            throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})");
        }
        fwrite($fp, $frameBin);
        stream_set_timeout($fp, 2);

        // 5) Antwort einlesen
        $response = '';
        while (!feof($fp)) {
            $chunk = fread($fp, 1024);
            if ($chunk === false || $chunk === '') {
                break;
            }
            $response .= $chunk;
        }
        fclose($fp);
        if ($response === '') {
            throw new Exception("Keine Antwort vom Inverter erhalten.");
        }

        // Debug: log raw response hex
        $respHex = strtoupper(bin2hex($response));
        $this->LogMessage("Raw response for single reg {$reg}: {$respHex}", KL_MESSAGE);

        // 6) Slice-Logik: l = 2*1 + 4 = 6, slice(-6, 2) → 2 Bytes
        $lModbus  = 2 * 1 + 4; // = 6
        $numBytes = 2;
        if (strlen($response) < $lModbus) {
            throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes).");
        }
        $dataBytes = substr($response, -$lModbus, $numBytes);
        if (strlen($dataBytes) < $numBytes) {
            throw new Exception("Data-Segment enthält weniger als {$numBytes} Bytes.");
        }
        $dataHex = strtoupper(bin2hex($dataBytes));
        $this->LogMessage("Sliced data for single reg {$reg}: {$dataHex}", KL_MESSAGE);

        return $dataBytes;
    }

    /**
     * Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um.
     *
     * @param string $decString
     * @return string 8-stellige Hex (uppercase)
     */
    private function decStringToHex8(string $decString): string
    {
        $num = ltrim($decString, '0');
        if ($num === '') {
            return '00000000';
        }
        $hex = '';
        while (bccomp($num, '0') > 0) {
            $mod   = bcmod($num, '16');
            $digit = dechex((int)$mod);
            $hex   = strtoupper($digit) . $hex;
            $num   = bcdiv($num, '16', 0);
        }
        return str_pad($hex, 8, '0', STR_PAD_LEFT);
    }

    /**
     * Kehrt die Byte-Reihenfolge eines Hex-Strings um (2 Hex-Zeichen = 1 Byte).
     *
     * @param string $hex  Hex-Repr. (z.B. "A1B2C3D4")
     * @return string      Umgekehrte Byte-Reihenfolge (z.B. "D4C3B2A1")
     */
    private function reverseByteOrder(string $hex): string
    {
        $bytes = str_split($hex, 2);
        $bytes = array_reverse($bytes);
        return implode('', $bytes);
    }

    /**
     * Konvertiert einen Hex-String in einen Dezimal-String (BCMath).
     *
     * @param string $hex Uppercase-Hex ohne Präfix (z.B. "00FF10A3")
     * @return string     Dezimal-String (z.B. "16737763")
     */
    private function hexToDecimal(string $hex): string
    {
        $hex = ltrim($hex, '0');
        if ($hex === '') {
            return '0';
        }
        $len     = strlen($hex);
        $dec     = '0';
        $power16 = '1';
        for ($i = $len - 1; $i >= 0; $i--) {
            $digit  = hexdec($hex[$i]);
            $term   = bcmul((string)$digit, $power16, 0);
            $dec    = bcadd($dec, $term, 0);
            $power16 = bcmul($power16, '16', 0);
        }
        return $dec;
    }

    /**
     * Berechnet CRC16-Modbus (Init=0xFFFF, Poly=0xA001) über Binärdaten.
     */
    private function calculateCRC16Modbus(string $binaryData): int
    {
        $crc = 0xFFFF;
        $len = strlen($binaryData);
        for ($pos = 0; $pos < $len; $pos++) {
            $crc ^= ord($binaryData[$pos]);
            for ($i = 0; $i < 8; $i++) {
                if (($crc & 0x0001) !== 0) {
                    $crc >>= 1;
                    $crc ^= 0xA001;
                } else {
                    $crc >>= 1;
                }
            }
        }
        return $crc;
    }

    /**
     * Prüft, ob eine Variable mit Ident existiert.
     */
    private function VariableExists(string $ident): bool
    {
        $vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
        return ($vid !== false && IPS_VariableExists($vid));
    }
}
