package blockchain;

import blockchain.body.BodyRecord;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Instant;
import java.util.Arrays;
import java.util.Objects;

/**
 * BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
 *
 * =========================================================================
 *  FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
 * =========================================================================
 *
 * Все числа BigEndian.
 *
 * PREIMAGE (входит в blockSize, подписывается):
 *   [2]  frameCode        (uint16)   код/версия рамки:
 *                                     - 0x0000 = Frame v0 (текущий)
 *   [32] prevHash32       (bytes)    SHA-256(preimage) предыдущего блока (цепочка)
 *   [4]  blockSize        (int32)    размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
 *                                   НО БЕЗ sigMarker и БЕЗ signature64
 *   [4]  blockNumber      (int32)    глобальный номер блока (>=0)
 *   [8]  timestamp        (int64)    unix seconds
 *   [2]  type             (uint16)   тип сообщения
 *   [2]  subType          (uint16)   подтип сообщения
 *   [2]  version          (uint16)   версия формата сообщения
 *   [N]  bodyBytes        (bytes)    тело сообщения (БЕЗ type/subType/version)
 *
 * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
 *   [2]  sigMarker        (uint16)   маркер подписи:
 *                                     - 0x0100 (256) = далее подпись Ed25519 64 байта
 *   [64] signature64      (bytes)    Ed25519 signature над hash32
 *
 * hash32 НЕ хранится в блоке.
 * hash32 вычисляется при парсинге:
 *   preimage = первые blockSize байт
 *   hash32   = SHA-256(preimage)
 *
 * Правила MVP-парсера (Frame v0):
 *  - frameCode должен быть строго 0x0000, иначе REJECT.
 *  - sigMarker должен быть строго 0x0100, иначе REJECT.
 *  - подпись обязана присутствовать всегда (sigMarker+signature64).
 *  - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
 *
 * Важно по безопасности:
 *  - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
 *    поэтому единственная безопасная логика: "если не 0x0100 — reject".
 * =========================================================================
 */
public final class BchBlockEntry {

    public static final int SIGNATURE_LEN = 64;
    public static final int HASH_LEN = 32;

    public static final int FRAME_CODE_LEN = 2;
    public static final int SIG_MARKER_LEN = 2;

    /** Frame v0 */
    public static final int FRAME_CODE_V0 = 0x0000;

    /** sigMarker: 256 = 0x0100 */
    public static final int SIG_MARKER_ED25519 = 0x0100;

    /**
     * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
     * чтобы не уложить сервер по памяти/диску.
     */
    public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;

    /**
     * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
     * Если timestamp больше now + 60 сек — блок считаем неверным.
     */
    public static final long MAX_FUTURE_SECONDS = 60;

    /**
     * Размер фиксированной части PREIMAGE (без bodyBytes).
     *
     * PREIMAGE header:
     *   frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
     *   + type(2) + subType(2) + version(2)
     */
    public static final int PREIMAGE_HEADER_SIZE =
            2   // frameCode
            + 32 // prevHash32
            + 4  // blockSize
            + 4  // blockNumber
            + 8  // timestamp
            + 2  // type
            + 2  // subType
            + 2; // version

    /** Минимальный полный размер блока (без bodyBytes). */
    public static final int MIN_FULL_BYTES =
            PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;

    // --- HEADER (PREIMAGE) ---
    public final int frameCode;        // uint16 (v0=0)
    public final byte[] prevHash32;    // 32
    public final int blockSize;        // preimage size (включая frameCode)
    public final int blockNumber;      // >=0
    public final long timestamp;
    public final short type;
    public final short subType;
    public final short version;

    // --- BODY (PREIMAGE) ---
    public final byte[] bodyBytes;

    /** Распарсенное тело (создаётся сразу при парсинге блока). */
    public final BodyRecord body;

    // --- TAIL ---
    public final int sigMarker;        // uint16 (v0: 0x0100)
    private final byte[] signature64;  // 64

    // --- derived ---
    private final byte[] hash32;       // 32, computed
    private final byte[] preimage;     // blockSize bytes
    private final byte[] fullBytes;    // preimage + sigMarker + signature

    /* ===================================================================== */
    /* ====================== Конструктор из байт ========================== */
    /* ===================================================================== */

    public BchBlockEntry(byte[] fullBytes) {
        Objects.requireNonNull(fullBytes, "fullBytes == null");

        if (fullBytes.length < MIN_FULL_BYTES) {
            throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
        }
        if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
            throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
        }

        ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);

        // [2] frameCode
        this.frameCode = Short.toUnsignedInt(bb.getShort());
        if (this.frameCode != FRAME_CODE_V0) {
            throw new IllegalArgumentException(String.format(
                    "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
            ));
        }

        // [32] prevHash32
        this.prevHash32 = new byte[32];
        bb.get(this.prevHash32);

        // [4] blockSize
        this.blockSize = bb.getInt();
        if (blockSize < PREIMAGE_HEADER_SIZE) {
            throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
        }

        // fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
        int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
        if (expectedFullLen != fullBytes.length) {
            throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
                    + " expectedFullLen=" + expectedFullLen
                    + " fullLen=" + fullBytes.length);
        }
        if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
            throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
        }

        // [4] blockNumber
        this.blockNumber = bb.getInt();
        if (this.blockNumber < 0) {
            throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
        }

        // [8] timestamp
        this.timestamp = bb.getLong();

        // запрет “в будущее” больше чем на 1 минуту
        long now = Instant.now().getEpochSecond();
        if (this.timestamp > now + MAX_FUTURE_SECONDS) {
            throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
                    + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
        }

        // [2][2][2] type/subType/version
        this.type = bb.getShort();
        this.subType = bb.getShort();
        this.version = bb.getShort();

        // [N] bodyBytes
        int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
        if (bodyLen < 0) {
            throw new IllegalArgumentException("Invalid body length: " + bodyLen);
        }
        this.bodyBytes = new byte[bodyLen];
        bb.get(this.bodyBytes);

        // TAIL: [2] sigMarker
        this.sigMarker = Short.toUnsignedInt(bb.getShort());
        if (this.sigMarker != SIG_MARKER_ED25519) {
            throw new IllegalArgumentException(String.format(
                    "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
            ));
        }

        // TAIL: [64] signature64
        this.signature64 = new byte[SIGNATURE_LEN];
        bb.get(this.signature64);

        // preimage = первые blockSize байт (включая frameCode)
        this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);

        // hash32 = sha256(preimage)
        this.hash32 = BchCryptoVerifier.sha256(preimage);

        // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
        this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);

        this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);

        if (bb.remaining() != 0) {
            throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
        }
    }

    /* ===================================================================== */
    /* ====================== Конструктор сборки ============================ */
    /* ===================================================================== */

    public BchBlockEntry(byte[] prevHash32,
                         int blockNumber,
                         long timestamp,
                         short type,
                         short subType,
                         short version,
                         byte[] bodyBytes,
                         byte[] signature64) {

        Objects.requireNonNull(prevHash32, "prevHash32 == null");
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");
        Objects.requireNonNull(signature64, "signature64 == null");

        if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
        if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");

        if (blockNumber < 0) {
            throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
        }

        // запрет “в будущее” больше чем на 1 минуту
        long now = Instant.now().getEpochSecond();
        if (timestamp > now + MAX_FUTURE_SECONDS) {
            throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
                    + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
        }

        this.frameCode = FRAME_CODE_V0;
        this.prevHash32 = Arrays.copyOf(prevHash32, 32);
        this.blockNumber = blockNumber;
        this.timestamp = timestamp;
        this.type = type;
        this.subType = subType;
        this.version = version;
        this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);

        // blockSize = размер preimage (включая frameCode)
        this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;

        int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
        if (fullLen > MAX_BLOCK_FULL_BYTES) {
            throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
        }

        // parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
        this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);

        // tail marker фиксирован
        this.sigMarker = SIG_MARKER_ED25519;
        this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);

        // build preimage
        ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
        pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
        pre.put(this.prevHash32);
        pre.putInt(this.blockSize);
        pre.putInt(this.blockNumber);
        pre.putLong(this.timestamp);
        pre.putShort(this.type);
        pre.putShort(this.subType);
        pre.putShort(this.version);
        pre.put(this.bodyBytes);

        this.preimage = pre.array();
        this.hash32 = BchCryptoVerifier.sha256(preimage);

        // build fullBytes: preimage + sigMarker + signature64
        ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
        full.put(this.preimage);
        full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
        full.put(this.signature64);
        this.fullBytes = full.array();
    }

    /* ===================================================================== */
    /* ============================ Getters ================================= */
    /* ===================================================================== */

    public byte[] getPreimageBytes() {
        return Arrays.copyOf(preimage, preimage.length);
    }

    /** Возвращает подпись Ed25519 (64 байта). */
    public byte[] getSignature64() {
        return Arrays.copyOf(signature64, SIGNATURE_LEN);
    }

    /** Возвращает hash32 = SHA-256(preimage). */
    public byte[] getHash32() {
        return Arrays.copyOf(hash32, HASH_LEN);
    }

    /** Возвращает полный блок: preimage + sigMarker + signature. */
    public byte[] toBytes() {
        return Arrays.copyOf(fullBytes, fullBytes.length);
    }

    @Override
    public String toString() {
        String timeIso;
        try {
            timeIso = Instant.ofEpochSecond(timestamp).toString();
        } catch (Exception e) {
            timeIso = "некорректныйTimestamp";
        }

        return "BchBlockEntry{"
                + "FRAME{frameCode=0x" + hex4(frameCode)
                + "}, HDR{"
                + "blockSize=" + blockSize
                + ", blockNumber=" + blockNumber
                + ", timestamp=" + timestamp + " (" + timeIso + ")"
                + ", type=" + (type & 0xFFFF)
                + ", subType=" + (subType & 0xFFFF)
                + ", version=" + (version & 0xFFFF)
                + ", prevHash32(hex)=" + toHex(prevHash32)
                + "}"
                + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
                + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
                + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
                + "}";
    }

    private static String hex4(int v) {
        String s = Integer.toHexString(v & 0xFFFF);
        while (s.length() < 4) s = "0" + s;
        return s;
    }

    private static String toHex(byte[] bytes) {
        if (bytes == null) return "null";
        char[] HEX = "0123456789abcdef".toCharArray();
        char[] out = new char[bytes.length * 2];
        for (int i = 0; i < bytes.length; i++) {
            int vv = bytes[i] & 0xFF;
            out[i * 2] = HEX[vv >>> 4];
            out[i * 2 + 1] = HEX[vv & 0x0F];
        }
        return new String(out);
    }
}
package blockchain;

import utils.crypto.Ed25519Util;

import java.security.MessageDigest;
import java.util.Objects;

/**
 * Верификатор SHiNE (Frame v0):
 *
 * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
 *          = всё до TAIL (sigMarker+signature).
 *
 * hash32   = SHA-256(preimage)
 * verify   = Ed25519.verify(hash32, signature64, pubKey32)
 */
public final class BchCryptoVerifier {

    private BchCryptoVerifier() {}

    public static byte[] sha256(byte[] data) {
        Objects.requireNonNull(data, "data == null");
        try {
            MessageDigest d = MessageDigest.getInstance("SHA-256");
            return d.digest(data);
        } catch (Exception e) {
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }

    public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
        Objects.requireNonNull(block, "block == null");
        Objects.requireNonNull(publicKey32, "publicKey32 == null");

        if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");

        byte[] hash32 = block.getHash32();
        byte[] sig64 = block.getSignature64();

        return Ed25519Util.verify(hash32, sig64, publicKey32);
    }
}
package blockchain.body;

/**
 * BodyHasLine — для типов, которые имеют линейные поля в body.
 *
 * Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes:
 *   [4]  lineCode                    код линии (root-идентификатор):
 *                                   - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0)
 *                                   - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL)
 *
 *   [4]  prevLineBlockGlobalNumber   глобальный номер предыдущего блока в этой линии
 *   [32] prevLineBlockHash32         hash32 предыдущего блока в этой линии
 *
 *   [4]  lineSeq                     порядковый номер сообщения внутри линии (1..N)
 *
 * Важно:
 *  - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq
 *    выполняется на сервере/в БД при вставке (а не в body.check()).
 */
public interface BodyHasLine {

    int lineCode();

    int prevLineBlockGlobalNumber();

    byte[] prevLineBlockHash32();

    int lineSeq();
}
package blockchain.body;

import utils.blockchain.BlockchainNameUtil;

/**
 * BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
 *
 * Новое правило:
 *  - toLogin НЕ храним в байтах блока.
 *  - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
 *
 * Все методы могут возвращать null.
 */
public interface BodyHasTarget {

    /** login цели (nullable). Вычисляется из toBchName(). */
    default String toLogin() {
        String bch = toBchName();
        if (bch == null) return null;
        return BlockchainNameUtil.loginFromBlockchainName(bch);
    }

    /** blockchainName цели (nullable). */
    String toBchName();

    /** globalNumber цели (nullable). */
    Integer toBlockGlobalNumber();

    /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
    byte[] toBlockHashBytes();
}
package blockchain.body;

/**
 * BodyRecord — общий контракт для всех типов body (тела блока).
 *
 * ВАЖНО (новый формат):
 * - type/subType/version НЕ лежат в bodyBytes.
 * - type/subType/version читаются из заголовка блока (BchBlockEntry).
 *
 * Поэтому из интерфейса УБРАНЫ:
 *  - type()
 *  - subType()
 *  - version()
 *  - expectedLineIndex()
 */
public interface BodyRecord {

    /** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */
    BodyRecord check();

    /**
     * Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes).
     * Важно: НЕ включает type/subType/version.
     */
    byte[] toBytes();
}
package blockchain.body;

import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * ConnectionBody — type=3, ver=1 (в заголовке блока).
 *
 * subType (в заголовке блока) как MsgSubType:
 *   FRIEND=10, UNFRIEND=11
 *   CONTACT=20, UNCONTACT=21
 *   FOLLOW=30, UNFOLLOW=31
 *
 * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *
 *   [1] toBlockchainNameLen (uint8)
 *   [N] toBlockchainName UTF-8
 *   [4] toBlockGlobalNumber (int32)
 *   [32] toBlockHash32 (raw 32 bytes)
 *
 * toLogin вычисляется автоматически из toBlockchainName:
 *   toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
 */

/**
 * =========================================================================
 *  ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
 * =========================================================================
 *
 *  Термины:
 *   - ROOT линии/канала = блок, который "начинает" линию:
 *       * для канала "0" root = HEADER (blockNumber=0)
 *       * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
 *
 *  1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
 *     FRIEND / CONTACT  -> цель ВСЕГДА HEADER пользователя:
 *       toBlockNumber = 0
 *       toBlockHash32 = hash32(HEADER цели)
 *
 *  2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
 *     FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
 *       toBlockNumber = 0
 *       toBlockHash32 = hash32(HEADER цели)
 *
 *     FOLLOW/подписка на конкретный канал пользователя ->
 *       цель = ROOT этого канала:
 *         - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
 *         - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
 *                     toBlockHash32=hash32(CREATE_CHANNEL)
 *
 *  3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
 *     - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
 *     - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
 *       разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
 *
 *  Зачем так:
 *   - связи и подписки всегда стабильны и не ломаются при новых постах,
 *   - один понятный инвариант: "подписка всегда указывает на root линии".
 * =========================================================================
 */

public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {

    public static final short TYPE = 3;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType; // из header
    public final short version; // из header

    // line
    public final int lineCode;
    public final int prevLineNumber;
    public final byte[] prevLineHash32;
    public final int thisLineNumber;

    // payload
    public final String toBlockchainName;
    public final int toBlockGlobalNumber;
    public final byte[] toBlockHash32;

    public ConnectionBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
        }
        if (!isValidSubType(this.subType)) {
            throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
        }

        // минимум:
        // lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
        if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
            throw new IllegalArgumentException("ConnectionBody too short");
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        this.lineCode = bb.getInt();

        this.prevLineNumber = bb.getInt();

        this.prevLineHash32 = new byte[32];
        bb.get(this.prevLineHash32);

        this.thisLineNumber = bb.getInt();

        int bchLen = Byte.toUnsignedInt(bb.get());
        if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
        if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");

        byte[] bchBytes = new byte[bchLen];
        bb.get(bchBytes);
        this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);

        this.toBlockGlobalNumber = bb.getInt();

        this.toBlockHash32 = new byte[32];
        bb.get(this.toBlockHash32);

        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
    }

    public ConnectionBody(int lineCode,
                          int prevLineNumber,
                          byte[] prevLineHash32,
                          int thisLineNumber,
                          short subType,
                          String toBlockchainName,
                          int toBlockGlobalNumber,
                          byte[] toBlockHash32) {

        Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
        Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");

        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
        if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));

        if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
        // Железное правило формата: bchName -> login + "-NNN"
        if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
            throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
        }

        if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

        this.lineCode = lineCode;

        this.prevLineNumber = prevLineNumber;
        this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        this.thisLineNumber = thisLineNumber;

        this.subType = subType;
        this.version = VER;

        this.toBlockchainName = toBlockchainName;
        this.toBlockGlobalNumber = toBlockGlobalNumber;
        this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
    }

    private static boolean isValidSubType(short st) {
        int v = st & 0xFFFF;
        return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
                || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
                || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
                || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
                || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
                || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
    }

    @Override
    public ConnectionBody check() {
        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
        if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));

        // line rule (как было)
        if (prevLineNumber == -1) {
            if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
            if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
        } else {
            if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
        }

        if (toBlockchainName == null || toBlockchainName.isBlank())
            throw new IllegalArgumentException("toBlockchainName is blank");

        // гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
        if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
            throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);

        if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
        if (bchBytes.length == 0 || bchBytes.length > 255)
            throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");

        if (toBlockHash32 == null || toBlockHash32.length != 32)
            throw new IllegalArgumentException("toBlockHash32 != 32");

        int cap = 4 + (4 + 32 + 4)
                + 1 + bchBytes.length
                + 4 + 32;

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);

        bb.putInt(lineCode);

        bb.putInt(prevLineNumber);
        bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        bb.putInt(thisLineNumber);

        bb.put((byte) bchBytes.length);
        bb.put(bchBytes);

        bb.putInt(toBlockGlobalNumber);
        bb.put(toBlockHash32);

        return bb.array();
    }

    private static boolean isAllZero32(byte[] b) {
        if (b == null || b.length != 32) return true;
        for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
        return true;
    }

    /* ====================== BodyHasLine ====================== */
    @Override public int lineCode() { return lineCode; }
    @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
    @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
    @Override public int lineSeq() { return thisLineNumber; }

    /* ====================== BodyHasTarget ===================== */
    @Override public String toBchName() { return toBlockchainName; }
    @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
    @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * CreateChannelBody — TECH сообщение создания канала.
 *
 * type=0, ver=1 (в заголовке блока)
 * subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
 *
 * Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
 *  - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
 *  - thisLineNumber: 1,2,3... (тех-нумерация)
 *
 * bodyBytes (BigEndian), новый формат line-prefix:
 *   [4]  lineCode         (для TECH линии обычно 0)
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *   [1]  channelNameLen (uint8)
 *   [N]  channelName UTF-8  (^[A-Za-z0-9_]+$)
 *
 * Важно:
 *  - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
 */
public final class CreateChannelBody implements BodyRecord, BodyHasLine {

    public static final short TYPE = 0;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;

    private static final byte[] ZERO32 = new byte[32];

    public final short subType;   // из header
    public final short version;   // из header

    // line
    public final int lineCode;
    public final int prevLineNumber;
    public final byte[] prevLineHash32; // 32
    public final int thisLineNumber;

    // payload
    public final String channelName;

    public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
        }
        if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
            throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
        }

        // минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
        if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
            throw new IllegalArgumentException("CreateChannelBody too short");
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        this.lineCode = bb.getInt();

        this.prevLineNumber = bb.getInt();

        this.prevLineHash32 = new byte[32];
        bb.get(this.prevLineHash32);

        this.thisLineNumber = bb.getInt();

        int nameLen = Byte.toUnsignedInt(bb.get());
        if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
        if (bb.remaining() != nameLen) {
            throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
        }

        byte[] nameBytes = new byte[nameLen];
        bb.get(nameBytes);

        this.channelName = new String(nameBytes, StandardCharsets.UTF_8);

        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
    }

    public CreateChannelBody(int lineCode,
                             int prevLineNumber,
                             byte[] prevLineHash32,
                             int thisLineNumber,
                             String channelName) {
        Objects.requireNonNull(channelName, "channelName == null");
        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");

        this.subType = SUBTYPE;
        this.version = VER;

        this.lineCode = lineCode;
        this.prevLineNumber = prevLineNumber;
        this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
        this.thisLineNumber = thisLineNumber;

        this.channelName = channelName;
    }

    @Override
    public CreateChannelBody check() {
        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");

        if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
            throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");

        if (channelName == null || channelName.isBlank())
            throw new IllegalArgumentException("channelName is blank");

        if (!channelName.matches("^[A-Za-z0-9_]+$"))
            throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$");

        if ("0".equals(channelName))
            throw new IllegalArgumentException("channelName \"0\" is reserved");

        // tech-line: prev обязателен (минимум HEADER=0)
        if (prevLineNumber < 0)
            throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
        if (prevLineHash32 == null || prevLineHash32.length != 32)
            throw new IllegalArgumentException("prevLineHash32 invalid");
        if (thisLineNumber <= 0)
            throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
        if (nameUtf8.length == 0 || nameUtf8.length > 255)
            throw new IllegalArgumentException("channelName utf8 len must be 1..255");

        int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);

        bb.putInt(lineCode);

        bb.putInt(prevLineNumber);
        bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
        bb.putInt(thisLineNumber);

        bb.put((byte) nameUtf8.length);
        bb.put(nameUtf8);

        return bb.array();
    }

    /* ====================== BodyHasLine ====================== */
    @Override public int lineCode() { return lineCode; }
    @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
    @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
    @Override public int lineSeq() { return thisLineNumber; }
}
package blockchain.body;

import utils.config.ShineSignatureConstants;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
 * HeaderBody — type=0, version=1.
 *
 * В новом формате type/subType/version живут в HEADER блока,
 * поэтому bodyBytes для HeaderBody содержат только payload:
 *
 * bodyBytes (BigEndian):
 *   [TAG_LEN] tag ASCII "SHiNE"
 *   [1] loginLength=N (uint8)
 *   [N] login UTF-8
 */
public final class HeaderBody implements BodyRecord {

    public static final short TYPE = 0;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    /** Для header subType всегда 0 (служебная совместимость). */
    public static final short SUBTYPE_COMPAT = 0;

    /** TAG формата (ASCII). */
    public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;

    private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
    private static final int TAG_LEN = TAG_ASCII.length;

    public final short subType; // всегда 0 (из заголовка блока)
    public final short version; // из заголовка блока
    public final String tag;    // "SHiNE"
    public final String login;

    /** Десериализация из payload bodyBytes (без type/subType/version). */
    public HeaderBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
            throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
        }
        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
        }

        // минимум: tag[TAG_LEN] + loginLen[1]
        if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short");

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        byte[] tagBytes = new byte[TAG_LEN];
        bb.get(tagBytes);
        String t = new String(tagBytes, StandardCharsets.US_ASCII);
        if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
        this.tag = t;

        int loginLen = Byte.toUnsignedInt(bb.get());
        if (loginLen <= 0 || bb.remaining() < loginLen)
            throw new IllegalArgumentException("Bad login length");

        byte[] loginBytes = new byte[loginLen];
        bb.get(loginBytes);
        this.login = new String(loginBytes, StandardCharsets.UTF_8);

        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
    }

    /** Создание “вручную”. */
    public HeaderBody(String login) {
        Objects.requireNonNull(login, "login == null");
        this.subType = SUBTYPE_COMPAT;
        this.version = VER;
        this.tag = TAG;
        this.login = login;
    }

    @Override
    public HeaderBody check() {
        if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
            throw new IllegalArgumentException("HeaderBody subType must be 0");

        if (login == null || login.isBlank())
            throw new IllegalArgumentException("Login is blank");
        if (!login.matches("^[A-Za-z0-9_]+$"))
            throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
        if (loginUtf8.length == 0 || loginUtf8.length > 255)
            throw new IllegalArgumentException("Login utf8 len must be 1..255");

        int cap = TAG_LEN + 1 + loginUtf8.length;

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
        bb.put(TAG_ASCII);
        bb.put((byte) loginUtf8.length);
        bb.put(loginUtf8);

        return bb.array();
    }

    @Override
    public String toString() {
        return """
                HeaderBody {
                  тип записи        : HEADER (type=0, ver=1)  [в заголовке блока]
                  subType           : 0 (compat)
                  тег формата       : "%s"
                  login владельца   : "%s"
                }
                """.formatted(tag, login);
    }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * ReactionBody — type=2, version=1 (в заголовке блока).
 *
 * subType (в заголовке блока):
 *   1 = LIKE
 *
 * bodyBytes (BigEndian), новый формат:
 *   [1] toBlockchainNameLen (uint8)
 *   [N] toBlockchainName UTF-8
 *   [4] toBlockGlobalNumber (int32)
 *   [32] toBlockHash32 (raw 32 bytes)
 *
 * ЛИНИИ НЕТ.
 */
public final class ReactionBody implements BodyRecord, BodyHasTarget {

    public static final short TYPE = 2;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType;   // из header
    public final short version;   // из header

    public final String toBlockchainName;
    public final int toBlockGlobalNumber;
    public final byte[] toBlockHash32;

    public ReactionBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
        }
        if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) {
            throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
        }

        // минимум: nameLen[1]+name[1]+global[4]+hash[32]
        if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        int nameLen = Byte.toUnsignedInt(bb.get());
        if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
        if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");

        byte[] nameBytes = new byte[nameLen];
        bb.get(nameBytes);
        this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);

        this.toBlockGlobalNumber = bb.getInt();

        this.toBlockHash32 = new byte[32];
        bb.get(this.toBlockHash32);

        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
    }

    public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
        Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
        Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");

        this.subType = MsgSubType.REACTION_LIKE;
        this.version = VER;

        if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
        if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

        this.toBlockchainName = toBlockchainName;
        this.toBlockGlobalNumber = toBlockGlobalNumber;
        this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
    }

    @Override
    public ReactionBody check() {
        if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF))
            throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));

        if (toBlockchainName == null || toBlockchainName.isBlank())
            throw new IllegalArgumentException("toBlockchainName is blank");
        if (toBlockGlobalNumber < 0)
            throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32 == null || toBlockHash32.length != 32)
            throw new IllegalArgumentException("toBlockHash32 invalid");

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
        if (nameBytes.length == 0 || nameBytes.length > 255)
            throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");

        int cap = 1 + nameBytes.length + 4 + 32;

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
        bb.put((byte) nameBytes.length);
        bb.put(nameBytes);
        bb.putInt(toBlockGlobalNumber);
        bb.put(toBlockHash32);

        return bb.array();
    }

    /* ====================== BodyHasTarget ====================== */

    @Override public String toBchName() { return toBlockchainName; }
    @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
    @Override public byte[] toBlockHashBytes() { return toBlockHash32; }
}
package blockchain;

import blockchain.body.*;

/**
 * Парсер body выбирает класс по header: type/subType/version,
 * потому что bodyBytes больше НЕ содержат type/subType/version.
 */
public final class BodyRecordParser {

    private BodyRecordParser() {}

    public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) {
        if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null");

        int t = type & 0xFFFF;
        int v = version & 0xFFFF;

        int key = (t << 16) | v;

        BodyRecord r = switch (key) {
            case HeaderBody.KEY -> {
                int st = subType & 0xFFFF;
                if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
                    yield new HeaderBody(subType, version, bodyBytes);
                }
                if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
                    yield new CreateChannelBody(subType, version, bodyBytes);
                }
                throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
            }

            // TEXT type=1 ver=1: выбираем класс по subType
            case TextBody.KEY -> {
                int st = subType & 0xFFFF;

                if (st == (MsgSubType.TEXT_POST & 0xFFFF)
                        || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
                    yield new TextLineBody(subType, version, bodyBytes);
                }

                if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)
                        || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
                    yield new TextReplyBody(subType, version, bodyBytes);
                }

                throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
            }

            case ReactionBody.KEY   -> new ReactionBody(subType, version, bodyBytes);
            case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
            case UserParamBody.KEY  -> new UserParamBody(subType, version, bodyBytes);

            default -> throw new IllegalArgumentException(String.format(
                    "Unknown body type/version from header: type=%d ver=%d subType=%d",
                    t, v, (subType & 0xFFFF)
            ));
        };

        return r.check();
    }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * TextBody — type=1, ver=1 (в заголовке блока).
 *
 * subType (в заголовке блока):
 *   10 = POST
 *   11 = EDIT_POST
 *   20 = REPLY
 *   21 = EDIT_REPLY
 *
 * =========================================================================
 * КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ:
 *
 * POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine.
 * В новом формате добавлен lineCode:
 *   lineCode = 0 для канала "0"
 *   lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL)
 *
 * REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах).
 *
 * =========================================================================
 * ФОРМАТЫ bodyBytes (BigEndian):
 *
 * 1) POST (subType=10):
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *   [2]  textLenBytes (uint16)
 *   [N]  text UTF-8
 *
 * 2) EDIT_POST (subType=11):
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *
 *   hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить):
 *     [4]  toBlockGlobalNumber
 *     [32] toBlockHash32
 *
 *   [2]  textLenBytes (uint16)
 *   [N]  text UTF-8
 *
 * 3) REPLY (subType=20) — НЕ в линии:
 *   hasTarget:
 *     [1]  toBlockchainNameLen (uint8)
 *     [N]  toBlockchainName UTF-8
 *     [4]  toBlockGlobalNumber
 *     [32] toBlockHash32
 *
 *   [2]  textLenBytes (uint16)
 *   [M]  text UTF-8
 *
 * 4) EDIT_REPLY (subType=21) — НЕ в линии:
 *   hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить):
 *     [4]  toBlockGlobalNumber
 *     [32] toBlockHash32
 *
 *   [2]  textLenBytes (uint16)
 *   [N]  text UTF-8
 */
public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine {

    public static final short TYPE = 1;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType;   // из header
    public final short version;   // из header

    // ===== line fields (только для POST/EDIT_POST) =====
    // Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые".
    public final int lineCode;         // только для line-message; иначе -1
    public final int prevLineNumber;
    public final byte[] prevLineHash32; // 32 or null
    public final int thisLineNumber;

    // ===== message text =====
    public final String message;

    // ===== target fields =====
    // REPLY: toBlockchainName + globalNumber + hash32
    // EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName)
    public final String toBlockchainName;     // nullable
    public final Integer toBlockGlobalNumber; // nullable
    public final byte[] toBlockHash32;        // nullable (но если target есть -> 32)

    /* ===================================================================== */
    /* ====================== Конструктор из байт ========================== */
    /* ===================================================================== */

    public TextBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF));
        }
        if (!isValidSubType(this.subType)) {
            throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF));
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        int st = this.subType & 0xFFFF;

        if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
            // POST: hasLine(lineCode+line) + text
            ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short");

            this.lineCode = bb.getInt();
            this.prevLineNumber = bb.getInt();
            this.prevLineHash32 = new byte[32];
            bb.get(this.prevLineHash32);
            this.thisLineNumber = bb.getInt();

            this.message = readStrictUtf8Len16(bb, "POST text");

            this.toBlockchainName = null;
            this.toBlockGlobalNumber = null;
            this.toBlockHash32 = null;

            ensureNoTail(bb, "POST");

        } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            // EDIT_POST: hasLine(lineCode+line) + target(no bch) + text
            ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short");

            this.lineCode = bb.getInt();
            this.prevLineNumber = bb.getInt();
            this.prevLineHash32 = new byte[32];
            bb.get(this.prevLineHash32);
            this.thisLineNumber = bb.getInt();

            int tgtNum = bb.getInt();
            byte[] tgtHash = new byte[32];
            bb.get(tgtHash);

            this.toBlockchainName = null;
            this.toBlockGlobalNumber = tgtNum;
            this.toBlockHash32 = tgtHash;

            this.message = readStrictUtf8Len16(bb, "EDIT_POST text");

            ensureNoTail(bb, "EDIT_POST");

        } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            // REPLY: target(with bch) + text (без line)
            ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");

            int nameLen = Byte.toUnsignedInt(bb.get());
            if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
            ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");

            byte[] nameBytes = new byte[nameLen];
            bb.get(nameBytes);
            this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);

            this.toBlockGlobalNumber = bb.getInt();

            this.toBlockHash32 = new byte[32];
            bb.get(this.toBlockHash32);

            this.message = readStrictUtf8Len16(bb, "REPLY text");

            // line fields отсутствуют в байтах
            this.lineCode = -1;
            this.prevLineNumber = -1;
            this.prevLineHash32 = null;
            this.thisLineNumber = -1;

            ensureNoTail(bb, "REPLY");

        } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            // EDIT_REPLY: target(no bch) + text (без line)
            ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");

            int tgtNum = bb.getInt();
            byte[] tgtHash = new byte[32];
            bb.get(tgtHash);

            this.toBlockchainName = null;
            this.toBlockGlobalNumber = tgtNum;
            this.toBlockHash32 = tgtHash;

            this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text");

            // line fields отсутствуют в байтах
            this.lineCode = -1;
            this.prevLineNumber = -1;
            this.prevLineHash32 = null;
            this.thisLineNumber = -1;

            ensureNoTail(bb, "EDIT_REPLY");

        } else {
            throw new IllegalArgumentException("Unsupported Text subType: " + st);
        }
    }

    /* ===================================================================== */
    /* ====================== Фабрики (удобно) ============================= */
    /* ===================================================================== */

    public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) {
        return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
                message, null, null, null);
    }

    public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
                                       int targetBlockNumber, byte[] targetHash32,
                                       String message) {
        return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
                message, null, targetBlockNumber, targetHash32);
    }

    public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) {
        return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1,
                message, toBlockchainName, targetBlockNumber, targetHash32);
    }

    public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) {
        return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1,
                message, null, targetBlockNumber, targetHash32);
    }

    /**
     * Универсальный конструктор “вручную”.
     * Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет).
     */
    public TextBody(short subType,
                    int lineCode,
                    int prevLineNumber,
                    byte[] prevLineHash32,
                    int thisLineNumber,
                    String message,
                    String toBlockchainName,
                    Integer toBlockGlobalNumber,
                    byte[] toBlockHash32) {

        Objects.requireNonNull(message, "message == null");

        if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
        if (message.isBlank()) throw new IllegalArgumentException("message is blank");

        this.subType = subType;
        this.version = VER;

        int st = subType & 0xFFFF;

        // line применима только к POST/EDIT_POST
        if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
            this.lineCode = lineCode;
            this.prevLineNumber = prevLineNumber;
            this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
            this.thisLineNumber = thisLineNumber;
        } else {
            this.lineCode = -1;
            this.prevLineNumber = -1;
            this.prevLineHash32 = null;
            this.thisLineNumber = -1;
        }

        this.message = message;

        // target правила
        if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
            this.toBlockchainName = null;
            this.toBlockGlobalNumber = null;
            this.toBlockHash32 = null;

        } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
            Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
            if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
            if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

            this.toBlockchainName = null; // по ТЗ: не хранить
            this.toBlockGlobalNumber = toBlockGlobalNumber;
            this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);

        } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
            Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
            Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
            if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
            if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
            if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

            this.toBlockchainName = toBlockchainName;
            this.toBlockGlobalNumber = toBlockGlobalNumber;
            this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);

        } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
            Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
            if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
            if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

            this.toBlockchainName = null; // по ТЗ: не хранить
            this.toBlockGlobalNumber = toBlockGlobalNumber;
            this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);

        } else {
            this.toBlockchainName = null;
            this.toBlockGlobalNumber = null;
            this.toBlockHash32 = null;
        }
    }

    private static boolean isValidSubType(short st) {
        int v = st & 0xFFFF;
        return v == (MsgSubType.TEXT_POST & 0xFFFF)
                || v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
                || v == (MsgSubType.TEXT_REPLY & 0xFFFF)
                || v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
    }

    @Override
    public TextBody check() {
        if (!isValidSubType(subType))
            throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));

        if (message == null || message.isBlank())
            throw new IllegalArgumentException("Text message is blank");

        int st = subType & 0xFFFF;

        // локальные проверки line (БД не трогаем)
        if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
            if (prevLineHash32 == null || prevLineHash32.length != 32)
                throw new IllegalArgumentException("prevLineHash32 invalid");
        } else {
            // reply/edit_reply: line отсутствует
            if (prevLineHash32 != null)
                throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash");
        }

        // target rules
        if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
            if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
                throw new IllegalArgumentException("POST must not contain target fields");

        } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            if (toBlockchainName != null)
                throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target");
            if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
                throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
            if (toBlockHash32 == null || toBlockHash32.length != 32)
                throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");

        } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            if (toBlockchainName == null || toBlockchainName.isBlank())
                throw new IllegalArgumentException("REPLY toBlockchainName is blank");
            if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
                throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid");
            if (toBlockHash32 == null || toBlockHash32.length != 32)
                throw new IllegalArgumentException("REPLY toBlockHash32 invalid");

        } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            if (toBlockchainName != null)
                throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target");
            if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
                throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid");
            if (toBlockHash32 == null || toBlockHash32.length != 32)
                throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid");
        }

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
        if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
        if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");

        int st = subType & 0xFFFF;

        if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
            // hasLine(lineCode+line) + text
            int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;

            ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
            bb.putInt(lineCode);
            bb.putInt(prevLineNumber);
            bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
            bb.putInt(thisLineNumber);
            bb.putShort((short) msgUtf8.length);
            bb.put(msgUtf8);
            return bb.array();

        } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            // hasLine(lineCode+line) + target(no bch) + text
            if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
            if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");

            int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;

            ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
            bb.putInt(lineCode);
            bb.putInt(prevLineNumber);
            bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
            bb.putInt(thisLineNumber);

            bb.putInt(toBlockGlobalNumber);
            bb.put(toBlockHash32);

            bb.putShort((short) msgUtf8.length);
            bb.put(msgUtf8);
            return bb.array();

        } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            // target(with bch) + text
            if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
            if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber");
            if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32");

            byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
            if (nameUtf8.length == 0 || nameUtf8.length > 255)
                throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");

            int cap = 1 + nameUtf8.length + 4 + 32
                    + 2 + msgUtf8.length;

            ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
            bb.put((byte) nameUtf8.length);
            bb.put(nameUtf8);
            bb.putInt(toBlockGlobalNumber);
            bb.put(toBlockHash32);

            bb.putShort((short) msgUtf8.length);
            bb.put(msgUtf8);
            return bb.array();

        } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            // target(no bch) + text
            if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber");
            if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32");

            int cap = (4 + 32) + 2 + msgUtf8.length;

            ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
            bb.putInt(toBlockGlobalNumber);
            bb.put(toBlockHash32);

            bb.putShort((short) msgUtf8.length);
            bb.put(msgUtf8);
            return bb.array();

        } else {
            throw new IllegalStateException("Unsupported Text subType: " + st);
        }
    }

    /* ===================================================================== */
    /* ========================== Helpers ================================== */
    /* ===================================================================== */

    private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
        int len = Short.toUnsignedInt(bb.getShort());
        if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
        if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");

        byte[] bytes = new byte[len];
        bb.get(bytes);

        var decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);

        try {
            String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
            if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
            return s;
        } catch (CharacterCodingException e) {
            throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
        }
    }

    private static void ensureMin(ByteBuffer bb, int need, String msg) {
        if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
    }

    private static void ensureNoTail(ByteBuffer bb, String ctx) {
        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
    }

    /* ====================== BodyHasLine ====================== */
    @Override public int lineCode() { return lineCode; }
    @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
    @Override public byte[] prevLineBlockHash32() {
        if (prevLineHash32 == null) return null;
        return Arrays.copyOf(prevLineHash32, 32);
    }
    @Override public int lineSeq() { return thisLineNumber; }

    /* ====================== BodyHasTarget ===================== */
    @Override public String toBchName() { return toBlockchainName; }
    @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
    @Override public byte[] toBlockHashBytes() { return toBlockHash32; }

    /* ===================================================================== */
    /* ===================== Удобные хелперы (для ChainState) =============== */
    /* ===================================================================== */

    /** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */
    public boolean isLineMessage() {
        int st = subType & 0xFFFF;
        return st == (MsgSubType.TEXT_POST & 0xFFFF)
                || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
    }

    /** true только для EDIT_POST / EDIT_REPLY. */
    public boolean isEditMessage() {
        int st = subType & 0xFFFF;
        return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
                || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
    }

    /** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */
    public boolean isReplyFamily() {
        int st = subType & 0xFFFF;
        return st == (MsgSubType.TEXT_REPLY & 0xFFFF)
                || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
    }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * TextLineBody — type=1, ver=1.
 *
 * subType:
 *  - POST      (10)
 *  - EDIT_POST (11)
 *
 * Формат bodyBytes (BigEndian):
 *
 * POST:
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *   [2]  textLenBytes (uint16)
 *   [N]  text UTF-8
 *
 * EDIT_POST:
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *   [4]  toBlockGlobalNumber (int32)
 *   [32] toBlockHash32
 *   [2]  textLenBytes (uint16)
 *   [N]  text UTF-8
 */
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {

    public static final short TYPE = 1;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType;   // из header
    public final short version;   // из header (=1)

    // line
    public final int lineCode;
    public final int prevLineNumber;
    public final byte[] prevLineHash32; // 32 (может быть нули)
    public final int thisLineNumber;

    // target (только для EDIT_POST)
    public final Integer toBlockGlobalNumber; // nullable для POST
    public final byte[] toBlockHash32;        // nullable для POST

    // text
    public final String message;

    /* ====================== parse from bytes ====================== */

    public TextLineBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
        }

        int st = this.subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st);
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        // минимум line + textLen(2)
        ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");

        this.lineCode = bb.getInt();
        this.prevLineNumber = bb.getInt();

        this.prevLineHash32 = new byte[32];
        bb.get(this.prevLineHash32);

        this.thisLineNumber = bb.getInt();

        if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            // нужен target
            ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
            int tgtNum = bb.getInt();
            byte[] tgtHash = new byte[32];
            bb.get(tgtHash);

            this.toBlockGlobalNumber = tgtNum;
            this.toBlockHash32 = tgtHash;

        } else {
            this.toBlockGlobalNumber = null;
            this.toBlockHash32 = null;
        }

        this.message = readStrictUtf8Len16(bb, "TextLineBody text");

        ensureNoTail(bb, "TextLineBody");
    }

    /* ====================== manual ctor ====================== */

    public TextLineBody(int lineCode,
                        int prevLineNumber,
                        byte[] prevLineHash32,
                        int thisLineNumber,
                        short subType,
                        Integer toBlockGlobalNumber,
                        byte[] toBlockHash32,
                        String message) {

        Objects.requireNonNull(message, "message == null");

        int st = subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST");
        }

        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
        if (message.isBlank()) throw new IllegalArgumentException("message is blank");

        this.subType = subType;
        this.version = VER;

        this.lineCode = lineCode;
        this.prevLineNumber = prevLineNumber;
        this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        this.thisLineNumber = thisLineNumber;

        if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
            Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
            if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
            if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

            this.toBlockGlobalNumber = toBlockGlobalNumber;
            this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
        } else {
            this.toBlockGlobalNumber = null;
            this.toBlockHash32 = null;
        }

        this.message = message;
    }

    @Override
    public TextLineBody check() {
        int st = subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF))
            throw new IllegalArgumentException("Bad TextLineBody subType: " + st);

        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
        if (prevLineHash32 == null || prevLineHash32.length != 32)
            throw new IllegalArgumentException("prevLineHash32 invalid");

        if (message == null || message.isBlank())
            throw new IllegalArgumentException("Text message is blank");

        if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
                throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
            if (toBlockHash32 == null || toBlockHash32.length != 32)
                throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
        } else {
            if (toBlockGlobalNumber != null || toBlockHash32 != null)
                throw new IllegalArgumentException("POST must not contain target fields");
        }

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
        if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
        if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");

        int st = subType & 0xFFFF;

        int cap;
        if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
            cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
        } else {
            // EDIT_POST
            if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
            if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
            cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
        }

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);

        bb.putInt(lineCode);
        bb.putInt(prevLineNumber);
        bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        bb.putInt(thisLineNumber);

        if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
            bb.putInt(toBlockGlobalNumber);
            bb.put(toBlockHash32);
        }

        bb.putShort((short) msgUtf8.length);
        bb.put(msgUtf8);

        return bb.array();
    }

    /* ====================== BodyHasLine ====================== */
    @Override public int lineCode() { return lineCode; }
    @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
    @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
    @Override public int lineSeq() { return thisLineNumber; }

    /* ====================== BodyHasTarget ===================== */
    @Override public String toBchName() { return null; } // по ТЗ: не хранить
    @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
    @Override public byte[] toBlockHashBytes() { return toBlockHash32; }

    /* ====================== helpers ====================== */

    public boolean isEditPost() {
        return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
    }

    private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
        int len = Short.toUnsignedInt(bb.getShort());
        if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
        if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");

        byte[] bytes = new byte[len];
        bb.get(bytes);

        var decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);

        try {
            String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
            if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
            return s;
        } catch (CharacterCodingException e) {
            throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
        }
    }

    private static void ensureMin(ByteBuffer bb, int need, String msg) {
        if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
    }

    private static void ensureNoTail(ByteBuffer bb, String ctx) {
        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
    }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * TextReplyBody — type=1, ver=1.
 *
 * subType:
 *  - REPLY      (20)
 *  - EDIT_REPLY (21)
 *
 * Форматы bodyBytes (BigEndian):
 *
 * REPLY:
 *   [1] toBlockchainNameLen (uint8)
 *   [N] toBlockchainName UTF-8
 *   [4] toBlockGlobalNumber
 *   [32] toBlockHash32
 *   [2] textLenBytes (uint16)
 *   [M] text UTF-8
 *
 * EDIT_REPLY:
 *   [4] toBlockGlobalNumber
 *   [32] toBlockHash32
 *   [2] textLenBytes (uint16)
 *   [N] text UTF-8
 */
public final class TextReplyBody implements BodyRecord, BodyHasTarget {

    public static final short TYPE = 1;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType;   // из header
    public final short version;   // (=1)

    // target
    public final String toBlockchainName;     // nullable для EDIT_REPLY
    public final int toBlockGlobalNumber;
    public final byte[] toBlockHash32;        // 32

    // text
    public final String message;

    public TextReplyBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
        }

        int st = this.subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            // минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
            ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");

            int nameLen = Byte.toUnsignedInt(bb.get());
            if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
            ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");

            byte[] nameBytes = new byte[nameLen];
            bb.get(nameBytes);
            this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);

            this.toBlockGlobalNumber = bb.getInt();

            this.toBlockHash32 = new byte[32];
            bb.get(this.toBlockHash32);

        } else {
            // EDIT_REPLY: target без имени
            ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");

            this.toBlockchainName = null;
            this.toBlockGlobalNumber = bb.getInt();

            this.toBlockHash32 = new byte[32];
            bb.get(this.toBlockHash32);
        }

        this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
        ensureNoTail(bb, "TextReplyBody");
    }

    public TextReplyBody(short subType,
                         int toBlockGlobalNumber,
                         byte[] toBlockHash32,
                         String toBlockchainName,
                         String message) {

        Objects.requireNonNull(message, "message == null");
        Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");

        int st = subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
            throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
        }

        if (message.isBlank()) throw new IllegalArgumentException("message is blank");
        if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");

        if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
            if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
            this.toBlockchainName = toBlockchainName;
        } else {
            // EDIT_REPLY: имя не хранить
            this.toBlockchainName = null;
        }

        this.subType = subType;
        this.version = VER;

        this.toBlockGlobalNumber = toBlockGlobalNumber;
        this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);

        this.message = message;
    }

    @Override
    public TextReplyBody check() {
        int st = subType & 0xFFFF;
        if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
            throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);

        if (message == null || message.isBlank())
            throw new IllegalArgumentException("Text message is blank");

        if (toBlockGlobalNumber < 0)
            throw new IllegalArgumentException("toBlockGlobalNumber < 0");
        if (toBlockHash32 == null || toBlockHash32.length != 32)
            throw new IllegalArgumentException("toBlockHash32 invalid");

        if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            if (toBlockchainName == null || toBlockchainName.isBlank())
                throw new IllegalArgumentException("REPLY toBlockchainName is blank");
        } else {
            if (toBlockchainName != null)
                throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
        }

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
        if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
        if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");

        int st = subType & 0xFFFF;

        if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
            if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");

            byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
            if (nameUtf8.length == 0 || nameUtf8.length > 255)
                throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");

            int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;

            ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
            bb.put((byte) nameUtf8.length);
            bb.put(nameUtf8);
            bb.putInt(toBlockGlobalNumber);
            bb.put(toBlockHash32);
            bb.putShort((short) msgUtf8.length);
            bb.put(msgUtf8);

            return bb.array();
        }

        // EDIT_REPLY
        int cap = (4 + 32) + 2 + msgUtf8.length;

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
        bb.putInt(toBlockGlobalNumber);
        bb.put(toBlockHash32);
        bb.putShort((short) msgUtf8.length);
        bb.put(msgUtf8);

        return bb.array();
    }

    /* ====================== BodyHasTarget ====================== */

    @Override public String toBchName() { return toBlockchainName; }
    @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
    @Override public byte[] toBlockHashBytes() { return toBlockHash32; }

    public boolean isEditReply() {
        return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
    }

    /* ====================== helpers ====================== */

    private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
        int len = Short.toUnsignedInt(bb.getShort());
        if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
        if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");

        byte[] bytes = new byte[len];
        bb.get(bytes);

        var decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);

        try {
            String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
            if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
            return s;
        } catch (CharacterCodingException e) {
            throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
        }
    }

    private static void ensureMin(ByteBuffer bb, int need, String msg) {
        if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
    }

    private static void ensureNoTail(ByteBuffer bb, String ctx) {
        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
    }
}
package blockchain.body;

import blockchain.MsgSubType;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * UserParamBody — type=4, ver=1 (в заголовке блока).
 *
 * subType (в заголовке блока):
 *   1 = TEXT_TEXT
 *
 * bodyBytes (BigEndian), новый формат:
 *   [4]  lineCode
 *   [4]  prevLineNumber
 *   [32] prevLineHash32
 *   [4]  thisLineNumber
 *
 *   [2] keyLenBytes   (uint16)
 *   [N] keyUtf8
 *
 *   [2] valueLenBytes (uint16)
 *   [M] valueUtf8
 */
public final class UserParamBody implements BodyRecord, BodyHasLine {

    public static final short TYPE = 4;
    public static final short VER  = 1;

    public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);

    public final short subType; // из header
    public final short version; // из header

    // line
    public final int lineCode;
    public final int prevLineNumber;
    public final byte[] prevLineHash32;
    public final int thisLineNumber;

    public final String paramKey;
    public final String paramValue;

    public UserParamBody(short subType, short version, byte[] bodyBytes) {
        Objects.requireNonNull(bodyBytes, "bodyBytes == null");

        this.subType = subType;
        this.version = version;

        if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
            throw new IllegalArgumentException("UserParamBody version must be 1, got=" + (this.version & 0xFFFF));
        }
        if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) {
            throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF));
        }

        // минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1)
        if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) {
            throw new IllegalArgumentException("UserParamBody too short");
        }

        ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);

        this.lineCode = bb.getInt();

        this.prevLineNumber = bb.getInt();

        this.prevLineHash32 = new byte[32];
        bb.get(this.prevLineHash32);

        this.thisLineNumber = bb.getInt();

        int keyLen = Short.toUnsignedInt(bb.getShort());
        if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0");
        if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short");

        byte[] keyBytes = new byte[keyLen];
        bb.get(keyBytes);

        int valLen = Short.toUnsignedInt(bb.getShort());
        if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0");
        if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short");

        byte[] valBytes = new byte[valLen];
        bb.get(valBytes);

        if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());

        this.paramKey = strictUtf8(keyBytes, "paramKey");
        this.paramValue = strictUtf8(valBytes, "paramValue");

        if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
        if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
    }

    public UserParamBody(int lineCode,
                         int prevLineNumber,
                         byte[] prevLineHash32,
                         int thisLineNumber,
                         String paramKey,
                         String paramValue) {

        Objects.requireNonNull(paramKey, "paramKey == null");
        Objects.requireNonNull(paramValue, "paramValue == null");

        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");

        this.subType = MsgSubType.USER_PARAM_TEXT_TEXT;
        this.version = VER;

        this.lineCode = lineCode;
        this.prevLineNumber = prevLineNumber;
        this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        this.thisLineNumber = thisLineNumber;

        if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
        if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");

        this.paramKey = paramKey;
        this.paramValue = paramValue;
    }

    @Override
    public UserParamBody check() {
        if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");

        if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF))
            throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF));

        if (prevLineNumber == -1) {
            if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
            if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
        } else {
            if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
        }

        if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
        if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");

        return this;
    }

    @Override
    public byte[] toBytes() {
        byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8);
        byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8);

        if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535");
        if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535");

        int cap = 4 + (4 + 32 + 4)
                + 2 + keyUtf8.length
                + 2 + valUtf8.length;

        ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);

        bb.putInt(lineCode);

        bb.putInt(prevLineNumber);
        bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
        bb.putInt(thisLineNumber);

        bb.putShort((short) keyUtf8.length);
        bb.put(keyUtf8);

        bb.putShort((short) valUtf8.length);
        bb.put(valUtf8);

        return bb.array();
    }

    private static String strictUtf8(byte[] bytes, String fieldName) {
        var decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);

        try {
            return decoder.decode(ByteBuffer.wrap(bytes)).toString();
        } catch (CharacterCodingException e) {
            throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
        }
    }

    private static boolean isAllZero32(byte[] b) {
        if (b == null || b.length != 32) return true;
        for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
        return true;
    }

    /* ====================== BodyHasLine ====================== */
    @Override public int lineCode() { return lineCode; }
    @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
    @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
    @Override public int lineSeq() { return thisLineNumber; }
}
//package blockchain;
//
///**
// * LineIndex — канонические номера линий блокчейна.
// *
// * Линия = независимая последовательность блоков внутри одного блокчейна.
// */
//public final class LineIndex {
//
//    private LineIndex() {}
//
//    public static final short HEADER      = 0; // genesis / идентификация
//    public static final short TEXT        = 1; // сообщения                                  да надо
//    public static final short REACTION    = 2; // реакции                          не надо
//    public static final short CONNECTION  = 3; // связи (friend/contact/follow)              да надо
//    public static final short USER_PARAM  = 4; // параметры профиля                          да надо
//}
package blockchain;

/**
 * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
 *
 * Правило:
 *  - НИКАКИХ "магических чисел" subType по проекту.
 *  - В тестах, в body-классах и в SQL-триггерах используем только эти константы.
 *
 * Важно:
 *  - Значения менять после релиза нельзя (иначе сломается совместимость).
 *
 * =========================================================================
 * Про EDIT-типы (важные правила, чтобы не было “двойных правок”):
 *
 * 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне).
 *    Никаких “я отредачу чужое” — нельзя.
 *
 * 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ:
 *    - EDIT_POST -> на исходный POST
 *    - EDIT_REPLY -> на исходный REPLY
 *    НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена).
 *
 * 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах,
 *    и существование цели на уровне check() не проверяется
 *    (check() БД не видит). Если цели нет — “никто не увидит” и ок.
 * =========================================================================
 */
public final class MsgSubType {

    private MsgSubType() {}

    /* ===================== HEADER (msg_type=0) ===================== */

    /** HeaderBody: subType всегда 0 (compat). */
    public static final short HEADER_COMPAT = 0;
    public static final short TECH_CREATE_CHANNEL = 1;

    /* ===================== TEXT (msg_type=1) ===================== */

    /**
     * POST — обычный пост в канале (в линии канала).
     * Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber).
     */
    public static final short TEXT_POST = 10;

    /**
     * EDIT_POST — редактирование ПОСТА.
     * Имеет hasLine (принадлежит линии канала)
     * И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName).
     */
    public static final short TEXT_EDIT_POST = 11;

    /**
     * REPLY — ответ на сообщение.
     * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
     * Может указывать на чужой блокчейн/чужую линию/чужой канал.
     */
    public static final short TEXT_REPLY = 20;

    /**
     * EDIT_REPLY — редактирование ОТВЕТА.
     * НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName).
     */
    public static final short TEXT_EDIT_REPLY = 21;

    /* ===================== REACTION (msg_type=2) ===================== */

    /** Лайк (LIKE). */
    public static final short REACTION_LIKE = 1;

    /* ===================== CONNECTION (msg_type=3) ===================== */

    /** Добавить в друзья. */
    public static final short CONNECTION_FRIEND = 10;
    /** Удалить из друзей. */
    public static final short CONNECTION_UNFRIEND = 11;

    /** Добавить в контакты. */
    public static final short CONNECTION_CONTACT = 20;
    /** Удалить из контактов. */
    public static final short CONNECTION_UNCONTACT = 21;

    /** Подписаться (follow). */
    public static final short CONNECTION_FOLLOW = 30;
    /** Отписаться (unfollow). */
    public static final short CONNECTION_UNFOLLOW = 31;

    /* ===================== USER_PARAM (msg_type=4) ===================== */

    /** Параметр профиля key/value (обе строки). */
    public static final short USER_PARAM_TEXT_TEXT = 1;
}
package utils.blockchain;

import java.util.Objects;

public final class BlockchainNameUtil {

    /**
     * Теперь новое правило:
     * blockchainName = login + "-"+ 3 цифры
     * Пример: "Dima-001" -> "Dima"
     *
     * Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4
     */
    public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4;

    private BlockchainNameUtil() {}

    /**
     * Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN").
     * Пример: "Dima-001" -> "Dima"
     */
    public static String loginFromBlockchainName(String blockchainName) {
        if (blockchainName == null) return null;

        String s = blockchainName.trim();
        if (!hasDashAnd3DigitsSuffix(s)) return null;

        return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
    }

    /**
     * Проверка правила:
     *  - blockchainName должен оканчиваться на "-"+3 цифры
     *  - blockchainName без суффикса "-NNN" должен равняться login
     *
     * ВАЖНО:
     *  - сравнение строгое (case-sensitive)
     *  - null/blank считаем невалидным
     */
    public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) {
        if (blockchainName == null || login == null) return false;

        String bn = blockchainName.trim();
        String lg = login.trim();

        if (bn.isEmpty() || lg.isEmpty()) return false;
        if (!hasDashAnd3DigitsSuffix(bn)) return false;

        String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
        return Objects.equals(extracted, lg);
    }

    private static boolean hasDashAnd3DigitsSuffix(String s) {
        if (s == null) return false;
        int len = s.length();
        if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false;

        int dashPos = len - 4;
        if (s.charAt(dashPos) != '-') return false;

        char c1 = s.charAt(len - 3);
        char c2 = s.charAt(len - 2);
        char c3 = s.charAt(len - 1);

        return isDigit(c1) && isDigit(c2) && isDigit(c3);
    }

    private static boolean isDigit(char c) {
        return c >= '0' && c <= '9';
    }
}
package utils.files;

import java.io.IOException;
import java.nio.file.*;
import java.util.Objects;

/**
 * ===============================================================
 *  FileStoreUtil — утилита работы с файлами в папке data/.
 *
 *  Теперь поддерживает:
 *   - основной файл блокчейна:   <blockchainName>.bch
 *   - временный файл блокчейна:  <blockchainName>.tmp_bch
 *
 *  Важное:
 *   - validateSimpleFileName() запрещает path traversal.
 *   - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
 *     иначе делает обычный REPLACE_EXISTING move.
 * ===============================================================
 */
public final class FileStoreUtil {

    /** Базовая папка для хранения всех файлов (создаётся автоматически). */
    public static final String DATA_DIR_NAME = "data";

    /** Расширение основного файла блокчейна. */
    public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";

    /** Расширение временного файла (старое+новое). */
    public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";

    private static final FileStoreUtil INSTANCE = new FileStoreUtil();

    private final Path dataDirPath;

    private FileStoreUtil() {
        this.dataDirPath = Paths.get(DATA_DIR_NAME);
        ensureDataDirExists();
    }

    public static FileStoreUtil getInstance() {
        return INSTANCE;
    }

    /* ===================================================================== */
    /* ======================== Базовые операции =========================== */
    /* ===================================================================== */

    public void newFile(String fileName, byte[] data) {
        Objects.requireNonNull(data, "data == null");
        Path target = resolveSafe(fileName);
        try {
            Files.write(target, data,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.TRUNCATE_EXISTING,
                    StandardOpenOption.WRITE);
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось записать файл: " + target, e);
        }
    }

    public void addDataToFile(String fileName, byte[] data) {
        Objects.requireNonNull(data, "data == null");
        Path target = resolveSafe(fileName);
        try {
            Files.write(target, data,
                    StandardOpenOption.CREATE,
                    StandardOpenOption.WRITE,
                    StandardOpenOption.APPEND);
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось дописать файл: " + target, e);
        }
    }

    public byte[] readAllDataFromFile(String fileName) {
        Path target = resolveSafe(fileName);
        if (!Files.exists(target)) {
            throw new IllegalStateException("Файл не найден: " + target);
        }
        try {
            return Files.readAllBytes(target);
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
        }
    }

    public boolean exists(String fileName) {
        Path target = resolveSafe(fileName);
        return Files.exists(target);
    }

    public long size(String fileName) {
        Path target = resolveSafe(fileName);
        try {
            return Files.size(target);
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
        }
    }

    /* ===================================================================== */
    /* ===================== Блокчейн-файлы по имени ======================= */
    /* ===================================================================== */

    /** <blockchainName>.bch */
    public String buildBlockchainFileName(String blockchainName) {
        validateSimpleFileName(blockchainName);
        return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
    }

    /** <blockchainName>.tmp_bch */
    public String buildBlockchainTmpFileName(String blockchainName) {
        validateSimpleFileName(blockchainName);
        return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
    }

    public Path resolveBlockchainPath(String blockchainName) {
        return resolveSafe(buildBlockchainFileName(blockchainName));
    }

    public Path resolveBlockchainTmpPath(String blockchainName) {
        return resolveSafe(buildBlockchainTmpFileName(blockchainName));
    }

    public byte[] readBlockchain(String blockchainName) {
        return readAllDataFromFile(buildBlockchainFileName(blockchainName));
    }

    public void writeBlockchainTmp(String blockchainName, byte[] data) {
        newFile(buildBlockchainTmpFileName(blockchainName), data);
    }

    /**
     * Атомарно заменить основной файл блокчейна временным:
     *   <name>.tmp_bch  ->  <name>.bch
     *
     * Стратегия:
     *  1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
     *  2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
     *
     * Важный нюанс:
     *  - атомарность гарантируется только в пределах одной файловой системы.
     */
    public void atomicReplaceBlockchainFile(String blockchainName) {
        Path tmp = resolveBlockchainTmpPath(blockchainName);
        Path main = resolveBlockchainPath(blockchainName);

        if (!Files.exists(tmp)) {
            throw new IllegalStateException("TMP-файл не найден: " + tmp);
        }

        try {
            // 1) Пытаемся атомарный move
            Files.move(tmp, main,
                    StandardCopyOption.REPLACE_EXISTING,
                    StandardCopyOption.ATOMIC_MOVE);
        } catch (AtomicMoveNotSupportedException e) {
            // 2) Если ФС не поддерживает атомарный move — делаем обычный replace
            try {
                Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
            } catch (IOException ex) {
                throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
            }
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
        }
    }

    /* ===================================================================== */
    /* ============================ Helpers ================================= */
    /* ===================================================================== */

    private void ensureDataDirExists() {
        try {
            if (!Files.exists(dataDirPath)) {
                Files.createDirectories(dataDirPath);
            }
        } catch (IOException e) {
            throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
        }
    }

    private Path resolveSafe(String fileName) {
        validateSimpleFileName(fileName);
        return dataDirPath.resolve(fileName);
    }

    /**
     * Валидация "простого имени":
     *  - запрещаем слэши, обратные слэши, ".."
     *  - запрещаем пустоту
     *
     * Важно: сюда у нас попадает и blockchainName (как часть имени файла),
     * поэтому blockchainName должен быть "простым": без путей.
     */
    private void validateSimpleFileName(String fileName) {
        Objects.requireNonNull(fileName, "fileName == null");
        if (fileName.isBlank()) {
            throw new IllegalArgumentException("Имя файла не должно быть пустым");
        }
        if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
            throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
        }
    }
}
