import { WsJsonClient } from './ws-client.js';
import {
  base64ToBytes,
  bytesToBase64,
  deriveEd25519FromMasterSecret,
  deriveMasterSecretFromPassword,
  exportEd25519PublicKeyB64,
  exportPkcs8B64,
  generateEd25519Pair,
  importPkcs8Ed25519,
  publicKeyB64FromPkcs8Ed25519,
  randomBase64,
  sha256Bytes,
  signBytes,
  signBase64,
  utf8Bytes,
} from './crypto-utils.js';
import {
  channelNameErrorText,
  normalizeChannelDisplayName,
  toCanonicalChannelSlug,
  validateChannelDisplayName,
} from './channel-name-rules.js';
import {
  loadEncryptedUserSecrets,
  loadSessionMaterial,
  saveEncryptedUserSecrets,
  saveSessionMaterial,
} from './key-vault.js';

const BCH_SUFFIX = '001';
const ZERO64 = '0'.repeat(64);
const ZERO_HASH_HEX = ZERO64;

const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;

const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_TEXT_REPOST = 30;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 1;
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const SESSION_TYPE_CLIENT = 1;

const CONNECTION_SUBTYPES = Object.freeze({
  // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
  close_friend: { on: 10, off: 11 },
  friend: { on: 10, off: 11 },
  contact: { on: 20, off: 21 },
  follow: { on: 30, off: 31 },
  spouse: { on: 40, off: 41 },
  parent: { on: 50, off: 51 },
  child: { on: 52, off: 53 },
  sibling: { on: 54, off: 55 },
  known_person: { on: 60, off: 61 },
  shine_confirmed: { on: 70, off: 71 },
  shine_seen: { on: 74, off: 75 },
});

function normalizeServerUrl(url) {
  const value = (url || '').trim();
  if (!value) return 'wss://shineup.me/ws';
  if (value.startsWith('ws://') || value.startsWith('wss://')) {
    try {
      const parsed = new URL(value);
      if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
      return parsed.toString();
    } catch {
      return value;
    }
  }
  if (value.startsWith('https://') || value.startsWith('http://')) {
    try {
      const parsed = new URL(value);
      parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
      if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
      return parsed.toString();
    } catch {
      return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
    }
  }
  return value;
}

function opError(op, response) {
  const payload = response?.payload || {};
  const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
  const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
  const error = new Error(`${op}: ${message} (${code})`);
  error.op = op;
  error.code = code;
  error.status = response?.status || 0;
  return error;
}

function makeClientInfo() {
  const ua = navigator.userAgent || 'unknown';
  return ua.slice(0, 50);
}

function makeClientPlatform() {
  return 'Web';
}

function hexToBytes(hex) {
  const clean = String(hex || '').trim().toLowerCase();
  if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
  const out = new Uint8Array(clean.length / 2);
  for (let i = 0; i < out.length; i += 1) {
    const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(byte)) throw new Error('Некорректный hex');
    out[i] = byte;
  }
  return out;
}

function normalizeHex32(value, fallback = ZERO64) {
  const raw = String(value || '').trim().toLowerCase();
  if (!raw) return fallback;
  if (/^0+$/.test(raw)) return ZERO64;
  if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
  return raw;
}

function concatBytes(...chunks) {
  const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
  const out = new Uint8Array(total);
  let offset = 0;
  chunks.forEach((chunk) => {
    out.set(chunk, offset);
    offset += chunk.length;
  });
  return out;
}

function int32Bytes(value) {
  const bytes = new Uint8Array(4);
  const view = new DataView(bytes.buffer);
  view.setInt32(0, Number(value), false);
  return bytes;
}

function int16Bytes(value) {
  const bytes = new Uint8Array(2);
  const view = new DataView(bytes.buffer);
  view.setUint16(0, Number(value), false);
  return bytes;
}

function int8Byte(value) {
  const n = Number(value);
  if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
  return new Uint8Array([n & 0xff]);
}

function int64Bytes(value) {
  const bytes = new Uint8Array(8);
  const view = new DataView(bytes.buffer);
  view.setBigInt64(0, BigInt(value), false);
  return bytes;
}

function uint16Bytes(value) {
  const bytes = new Uint8Array(2);
  const view = new DataView(bytes.buffer);
  view.setUint16(0, Number(value), false);
  return bytes;
}

function uint32Bytes(value) {
  const bytes = new Uint8Array(4);
  const view = new DataView(bytes.buffer);
  view.setUint32(0, Number(value), false);
  return bytes;
}

function uint64Bytes(value) {
  const bytes = new Uint8Array(8);
  const view = new DataView(bytes.buffer);
  view.setBigUint64(0, BigInt(value), false);
  return bytes;
}

function uint8Bytes(value) {
  return new Uint8Array([Number(value) & 0xff]);
}

const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
const DM2_TYPE_INCOMING = 1;
const DM2_TYPE_OUTGOING_COPY = 2;
const DM2_TYPE_READ_INCOMING = 3;
const DM2_TYPE_READ_OUTGOING_COPY = 4;

function ensureAsciiBytes(value, field, min = 1, max = 60) {
  const text = String(value || '').trim();
  const bytes = utf8Bytes(text);
  if (bytes.length < min || bytes.length > max) {
    throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`);
  }
  for (let i = 0; i < bytes.length; i += 1) {
    const code = bytes[i];
    if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`);
  }
  return bytes;
}

function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
  return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
}

function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
  return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
}

function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
  const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
  const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
  return concatBytes(
    uint8Bytes(toBytes.length), toBytes,
    uint8Bytes(fromBytes.length), fromBytes,
    uint64Bytes(refTimeMs),
    uint32Bytes(refNonce),
    uint16Bytes(refType),
  );
}

function parseSignedMessageBlockBytes(bytes) {
  if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
  let o = 0;
  const read = (n) => {
    if (o + n > bytes.length) throw new Error('BAD_LEN');
    const out = bytes.slice(o, o + n);
    o += n;
    return out;
  };
  const readU8 = () => read(1)[0];
  const readU16 = () => {
    const part = read(2);
    const view = new DataView(part.buffer, part.byteOffset, 2);
    return view.getUint16(0, false);
  };
  const readU32 = () => {
    const part = read(4);
    const view = new DataView(part.buffer, part.byteOffset, 4);
    return view.getUint32(0, false);
  };
  const readU64 = () => {
    const part = read(8);
    const view = new DataView(part.buffer, part.byteOffset, 8);
    return Number(view.getBigUint64(0, false));
  };
  const readAscii = () => {
    const len = readU8();
    const part = read(len);
    const text = new TextDecoder().decode(part);
    for (let i = 0; i < part.length; i += 1) {
      const c = part[i];
      if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII');
    }
    return text;
  };

  const prefix = read(DM2_PREFIX.length);
  for (let i = 0; i < DM2_PREFIX.length; i += 1) {
    if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
  }

  const toLogin = readAscii();
  const fromLogin = readAscii();
  const timeMs = readU64();
  const nonce = readU32();
  const messageType = readU16();
  const payloadLen = readU16();
  const payloadBytes = read(payloadLen);
  const signatureBytes = read(64);
  if (o !== bytes.length) throw new Error('BAD_LEN');

  const signedBody = bytes.slice(0, bytes.length - 64);
  const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
  const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
  return {
    toLogin,
    fromLogin,
    timeMs,
    nonce,
    messageType,
    payloadBytes,
    signatureBytes,
    signedBody,
    rawBytes: bytes,
    baseKey,
    messageKey,
  };
}

function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
  const keyBytes = utf8Bytes(String(key || ''));
  const valueBytes = utf8Bytes(String(value || ''));
  const prevHashBytes = hexToBytes(prevLineHashHex);
  if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра');
  if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    prevHashBytes,
    int32Bytes(thisLineNumber),
    int16Bytes(keyBytes.length),
    keyBytes,
    int16Bytes(valueBytes.length),
    valueBytes,
  );
}

function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
  const cleanBch = String(toBlockchainName || '').trim();
  if (!cleanBch) throw new Error('toBlockchainName is required for like');

  const blockNumber = Number(toBlockNumber);
  if (!Number.isFinite(blockNumber) || blockNumber < 0) {
    throw new Error('Invalid toBlockNumber for like');
  }

  const bchBytes = utf8Bytes(cleanBch);
  if (bchBytes.length < 1 || bchBytes.length > 255) {
    throw new Error('toBlockchainName must be 1..255 bytes');
  }

  return concatBytes(
    int8Byte(bchBytes.length),
    bchBytes,
    int32Bytes(blockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex))
  );
}

function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
  const cleanBch = String(toBlockchainName || '').trim();
  if (!cleanBch) throw new Error('toBlockchainName is required for reply');

  const blockNumber = Number(toBlockNumber);
  if (!Number.isFinite(blockNumber) || blockNumber < 0) {
    throw new Error('Invalid toBlockNumber for reply');
  }

  const message = String(text || '').trim();
  if (!message) throw new Error('Reply text is required');

  const bchBytes = utf8Bytes(cleanBch);
  if (bchBytes.length < 1 || bchBytes.length > 255) {
    throw new Error('toBlockchainName must be 1..255 bytes');
  }

  const textBytes = utf8Bytes(message);
  if (textBytes.length < 1 || textBytes.length > 65535) {
    throw new Error('Reply text must be 1..65535 UTF-8 bytes');
  }

  return concatBytes(
    int8Byte(bchBytes.length),
    bchBytes,
    int32Bytes(blockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex)),
    int16Bytes(textBytes.length),
    textBytes
  );
}

function makeTextRepostBodyBytes({
  lineCode,
  prevLineNumber,
  prevLineHashHex,
  thisLineNumber,
  toBlockchainName,
  toBlockNumber,
  toBlockHashHex,
  text,
}) {
  const message = String(text || '').trim();
  if (!message) throw new Error('Комментарий к репосту обязателен');

  const bch = String(toBlockchainName || '').trim();
  if (!bch) throw new Error('toBlockchainName is required for repost');
  const bchBytes = utf8Bytes(bch);
  if (bchBytes.length < 1 || bchBytes.length > 255) {
    throw new Error('toBlockchainName must be 1..255 bytes');
  }

  const blockNumber = Number(toBlockNumber);
  if (!Number.isFinite(blockNumber) || blockNumber < 0) {
    throw new Error('Invalid toBlockNumber for repost');
  }

  const textBytes = utf8Bytes(message);
  if (textBytes.length < 1 || textBytes.length > 65535) {
    throw new Error('Repost comment must be 1..65535 UTF-8 bytes');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int8Byte(bchBytes.length),
    bchBytes,
    int32Bytes(blockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex)),
    int16Bytes(textBytes.length),
    textBytes,
  );
}

function makeTextEditPostBodyBytes({
  lineCode,
  prevLineNumber,
  prevLineHashHex,
  thisLineNumber,
  toBlockNumber,
  toBlockHashHex,
  text,
}) {
  const message = String(text || '').trim();
  const targetBlockNumber = Number(toBlockNumber);
  if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
    throw new Error('Invalid target block number for edit post');
  }
  const textBytes = utf8Bytes(message);
  if (textBytes.length > 65535) {
    throw new Error('Message text must be 0..65535 UTF-8 bytes');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int32Bytes(targetBlockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex)),
    int16Bytes(textBytes.length),
    textBytes,
  );
}

function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
  const message = String(text || '').trim();
  const targetBlockNumber = Number(toBlockNumber);
  if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
    throw new Error('Invalid target block number for edit reply');
  }
  const textBytes = utf8Bytes(message);
  if (textBytes.length > 65535) {
    throw new Error('Message text must be 0..65535 UTF-8 bytes');
  }

  return concatBytes(
    int32Bytes(targetBlockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex)),
    int16Bytes(textBytes.length),
    textBytes,
  );
}

function makeConnectionBodyBytes({
  lineCode = 0,
  prevLineNumber = -1,
  prevLineHashHex = ZERO64,
  thisLineNumber = -1,
  toBlockchainName,
  toBlockNumber,
  toBlockHashHex,
}) {
  const cleanBch = String(toBlockchainName || '').trim();
  if (!cleanBch) throw new Error('toBlockchainName is required for connection');

  const blockNumber = Number(toBlockNumber);
  if (!Number.isFinite(blockNumber) || blockNumber < 0) {
    throw new Error('Invalid toBlockNumber for connection');
  }

  const bchBytes = utf8Bytes(cleanBch);
  if (bchBytes.length < 1 || bchBytes.length > 255) {
    throw new Error('toBlockchainName must be 1..255 bytes');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int8Byte(bchBytes.length),
    bchBytes,
    int32Bytes(blockNumber),
    hexToBytes(normalizeHex32(toBlockHashHex))
  );
}

function normalizeChannelDescription(value) {
  const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
  const bytes = utf8Bytes(text);
  if (bytes.length > 200) {
    throw new Error('Описание канала слишком длинное: максимум 200 символов.');
  }
  return text;
}

function validatePersonalChannelName(value) {
  const normalized = normalizeChannelDisplayName(value);
  if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
  const length = Array.from(normalized).length;
  if (length < 1 || length > 20) {
    return { ok: false, error: 'Логин: 1-20 символов.' };
  }
  if (!/^[A-Za-z0-9_]+$/.test(normalized)) {
    return { ok: false, error: 'Логин: разрешены только латиница, цифры и _.' };
  }
  return { ok: true, normalized };
}

function makeCreateChannelBodyBytes({
  lineCode,
  prevLineNumber,
  prevLineHashHex,
  thisLineNumber,
  channelName,
  channelDescription = '',
  channelType = CHANNEL_TYPE_PUBLIC,
  channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
}) {
  const typeCode = Number(channelType);
  const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
    ? validatePersonalChannelName(channelName)
    : validateChannelDisplayName(channelName);
  if (!nameCheck.ok) {
    throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
  }
  const cleanName = nameCheck.normalized;
  const cleanDescription = normalizeChannelDescription(channelDescription);

  const nameBytes = utf8Bytes(cleanName);
  if (nameBytes.length < 1 || nameBytes.length > 255) {
    throw new Error('Channel name must be 1..255 bytes');
  }

  const descriptionBytes = utf8Bytes(cleanDescription);
  if (descriptionBytes.length > 200) {
    throw new Error('Описание канала слишком длинное: максимум 200 символов.');
  }
  const typeVer = Number(channelTypeVersion);
  if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
    throw new Error('Некорректный тип канала.');
  }
  if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
    throw new Error('Некорректная версия типа канала.');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int8Byte(nameBytes.length),
    nameBytes,
    int16Bytes(descriptionBytes.length),
    descriptionBytes,
    uint16Bytes(typeCode),
    uint16Bytes(typeVer),
  );
}

function makeCreateChannelBodyBytesLegacy({
  lineCode,
  prevLineNumber,
  prevLineHashHex,
  thisLineNumber,
  channelName,
}) {
  const check = validatePersonalChannelName(channelName);
  if (!check.ok) throw new Error(check.error);
  const cleanName = check.normalized;
  const nameBytes = utf8Bytes(cleanName);
  if (nameBytes.length < 1 || nameBytes.length > 255) {
    throw new Error('Channel name must be 1..255 bytes');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int8Byte(nameBytes.length),
    nameBytes,
  );
}

function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
  const message = String(text || '').trim();
  if (!message) throw new Error('Message text is required');

  const textBytes = utf8Bytes(message);
  if (textBytes.length < 1 || textBytes.length > 65535) {
    throw new Error('Message text must be 1..65535 UTF-8 bytes');
  }

  return concatBytes(
    int32Bytes(lineCode),
    int32Bytes(prevLineNumber),
    hexToBytes(normalizeHex32(prevLineHashHex)),
    int32Bytes(thisLineNumber),
    int16Bytes(textBytes.length),
    textBytes
  );
}

function normalizeMessageRefTarget(target, actionName = 'action') {
  const cleanBch = String(target?.blockchainName || '').trim();
  const cleanBlockNumber = Number(target?.blockNumber);
  const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();

  if (!cleanBch) {
    throw new Error(`Missing message target blockchain for ${actionName}`);
  }
  if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
    throw new Error(`Invalid message target block number for ${actionName}`);
  }
  if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
    throw new Error(`Invalid message target hash for ${actionName}`);
  }

  return {
    blockchainName: cleanBch,
    blockNumber: cleanBlockNumber,
    blockHash: cleanBlockHash,
  };
}

function resolveLatestLineStep(message) {
  const lineStep = Number(message?.lineStep);
  if (Number.isFinite(lineStep) && lineStep >= 0) return lineStep;
  const fallbackByVersions = Number(message?.versionsTotal);
  if (Number.isFinite(fallbackByVersions) && fallbackByVersions > 0) return Math.max(0, fallbackByVersions - 1);
  return null;
}

function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
  const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
  const body = bodyBytes || new Uint8Array(0);
  const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;

  return concatBytes(
    int16Bytes(0),
    prevHashBytes,
    int32Bytes(blockSize),
    int32Bytes(blockNumber),
    int64Bytes(Math.floor(Date.now() / 1000)),
    int16Bytes(msgType),
    int16Bytes(msgSubType),
    int16Bytes(msgVersion),
    body
  );
}

export class AuthService {
  constructor(serverUrl) {
    this.serverUrl = normalizeServerUrl(serverUrl);
    this.ws = new WsJsonClient(this.serverUrl);
    this.headerHashCache = new Map();
    this.writeLocks = new Map();
    this.passwordKeyBundleCache = new Map();
    this.passwordKeyBundleInFlight = new Map();
  }

  async reconnect(serverUrl) {
    const normalized = normalizeServerUrl(serverUrl);
    if (normalized === this.serverUrl) return;
    this.ws.close();
    this.serverUrl = normalized;
    this.ws = new WsJsonClient(this.serverUrl);
    this.headerHashCache = new Map();
    this.writeLocks.clear();
  }

  runWriteLocked(lockKey, runAction) {
    const key = String(lockKey || '').trim() || 'write';
    if (this.writeLocks.has(key)) return this.writeLocks.get(key);

    const task = (async () => runAction())().finally(() => {
      this.writeLocks.delete(key);
    });

    this.writeLocks.set(key, task);
    return task;
  }

  async getUser(login) {
    const response = await this.ws.request('GetUser', { login });
    if (response.status !== 200) throw opError('GetUser', response);
    return response.payload || {};
  }

  async ensureLoginFree(login) {
    const payload = await this.getUser(login);
    return payload.exists !== true;
  }

  async derivePasswordKeyBundle(login, password, options = {}) {
    const normalizedLogin = String(login ?? '');
    const normalizedPassword = String(password ?? '');
    const cacheKey = `${normalizedLogin}\n${normalizedPassword}`;
    const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
    const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null;

    if (this.passwordKeyBundleCache.has(cacheKey)) {
      if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' });
      return this.passwordKeyBundleCache.get(cacheKey);
    }
    if (this.passwordKeyBundleInFlight.has(cacheKey)) {
      return this.passwordKeyBundleInFlight.get(cacheKey);
    }

    const task = (async () => {
      if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' });
      if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');

      const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, {
        login: normalizedLogin,
        onProgress: (value01) => {
          if (!onProgress) return;
          const v = Math.max(0, Math.min(1, Number(value01) || 0));
          const percent = Math.round(5 + (v * 88));
          onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' });
        },
      });
      if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');

      if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' });
      const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
      if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');

      if (onProgress) onProgress({ percent: 96, stage: 'derive', message: 'Вычисление blockchain key...' });
      const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'bch.key');
      if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');

      if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
      const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key');
      const result = {
        masterSecretB64: bytesToBase64(masterSecret),
        rootPair,
        blockchainPair,
        devicePair,
      };
      this.passwordKeyBundleCache.set(cacheKey, result);
      if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
      return result;
    })().finally(() => {
      this.passwordKeyBundleInFlight.delete(cacheKey);
    });

    this.passwordKeyBundleInFlight.set(cacheKey, task);
    return task;
  }

  async createAuthSession(login, keyBundle) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Введите логин');

    const sessionPair = await generateEd25519Pair();
    const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
    const sessionKey = `ed25519/${sessionKeyPub}`;
    const storagePwd = randomBase64(32);

    const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
    if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);

    const authNonce = challengeResp?.payload?.authNonce;
    if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');

    const timeMs = Date.now();
    const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
    const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);

    const createResp = await this.ws.request('CreateAuthSession', {
      login: cleanLogin,
      storagePwd,
      sessionKey,
      timeMs,
      authNonce,
      deviceKey: keyBundle.devicePair.publicKeyB64,
      signatureB64,
      sessionType: SESSION_TYPE_CLIENT,
      clientPlatform: makeClientPlatform(),
      clientInfo: makeClientInfo(),
    });
    if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);

    const sessionId = createResp?.payload?.sessionId;
    if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');

    return {
      login: cleanLogin,
      sessionId,
      storagePwd,
      sessionMaterial: {
        sessionId,
        sessionKey,
        sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
      },
    };
  }

  async registerUser(login, password) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Введите логин');

    const isFree = await this.ensureLoginFree(cleanLogin);
    if (!isFree) throw new Error('Этот логин уже занят');

    const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);

    const addResp = await this.ws.request('AddUser', {
      login: cleanLogin,
      blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
      solanaKey: keyBundle.devicePair.publicKeyB64,
      blockchainKey: keyBundle.blockchainPair.publicKeyB64,
      deviceKey: keyBundle.devicePair.publicKeyB64,
      bchLimit: 1000000,
    });
    if (addResp.status !== 200) throw opError('AddUser', addResp);

    const session = await this.createAuthSession(cleanLogin, keyBundle);
    return { ...session, keyBundle };
  }

  async registerUserWithKeyBundle(login, keyBundle) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Введите логин');

    const addResp = await this.ws.request('AddUser', {
      login: cleanLogin,
      blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
      solanaKey: keyBundle.devicePair.publicKeyB64,
      blockchainKey: keyBundle.blockchainPair.publicKeyB64,
      deviceKey: keyBundle.devicePair.publicKeyB64,
      bchLimit: 1000000,
    });
    if (addResp.status !== 200) throw opError('AddUser', addResp);

    const session = await this.createAuthSession(cleanLogin, keyBundle);
    return { ...session, keyBundle };
  }

  async createSessionForExistingUser(login, password) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Введите логин');

    const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
    const session = await this.createAuthSession(cleanLogin, keyBundle);
    return { ...session, keyBundle };
  }

  async createSessionFromImportedSecrets(login, secrets) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('В QR-коде нет логина');
    const deviceKey = String(secrets?.deviceKey || '').trim();
    if (!deviceKey) throw new Error('В QR-коде нет device key для входа');

    const privateKey = await importPkcs8Ed25519(deviceKey);
    const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey);
    const session = await this.createAuthSession(cleanLogin, {
      devicePair: {
        privateKey,
        publicKeyB64,
      },
    });
    return session;
  }

  async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
    let currentSecrets = {};
    try {
      const loaded = await loadEncryptedUserSecrets(login, storagePwd);
      if (loaded && typeof loaded === 'object') {
        currentSecrets = loaded;
      }
    } catch {
      // Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
    }

    const secrets = {
      ...currentSecrets,
      deviceKey: keyBundle.devicePair.privatePkcs8B64,
    };
    if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
    if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
    await saveEncryptedUserSecrets(login, storagePwd, secrets);
  }

  async persistSessionMaterial(login, sessionMaterial) {
    await saveSessionMaterial(login, sessionMaterial);
  }


  async resumeSession(login, preferredSessionId = '') {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Нет login для авто-входа');

    const sessionMaterial = await loadSessionMaterial(cleanLogin);
    if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
      throw new Error('На устройстве нет сохраненного ключа сессии');
    }

    const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
    const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);

    const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
    if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);

    const nonce = challengeResp?.payload?.nonce;
    if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');

    const timeMs = Date.now();
    const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
    const signatureB64 = await signBase64(privateKey, preimage);

    const loginResp = await this.ws.request('SessionLogin', {
      sessionId: targetSessionId,
      sessionKey: sessionMaterial.sessionKey,
      timeMs,
      signatureB64,
      sessionType: SESSION_TYPE_CLIENT,
      clientPlatform: makeClientPlatform(),
      clientInfo: makeClientInfo(),
    });
    if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);

    const storagePwd = loginResp?.payload?.storagePwd;
    if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');

    return {
      login: cleanLogin,
      sessionId: targetSessionId,
      storagePwd,
    };
  }

  async listSessions() {
    const response = await this.ws.request('ListSessions', {});
    if (response.status !== 200) throw opError('ListSessions', response);
    return response?.payload?.sessions || [];
  }

  async closeSession(sessionId) {
    const response = await this.ws.request('CloseActiveSession', { sessionId });
    if (response.status !== 200) throw opError('CloseActiveSession', response);
  }

  async upsertEspPairingSettings({ enabled, passwordHash = '', ttlSeconds = 180 }) {
    const response = await this.ws.request('UpsertEspPairingSettings', {
      enabled: !!enabled,
      passwordHash: String(passwordHash || '').trim(),
      ttlSeconds: Number(ttlSeconds) || 180,
    });
    if (response.status !== 200) throw opError('UpsertEspPairingSettings', response);
    return response.payload || {};
  }

  async startEspPairing({
    login,
    passwordHash,
    requesterSessionKey,
    requesterSessionType = SESSION_TYPE_CLIENT,
    requesterClientPlatform = makeClientPlatform(),
    payloadType = 3,
  }) {
    const response = await this.ws.request('StartEspPairing', {
      login: String(login || '').trim(),
      passwordHash: String(passwordHash || '').trim(),
      requesterSessionKey: String(requesterSessionKey || '').trim(),
      requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
      requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
      payloadType: Number(payloadType) || 3,
    });
    if (response.status !== 200) throw opError('StartEspPairing', response);
    return response.payload || {};
  }

  async listEspPairingRequests() {
    const response = await this.ws.request('ListEspPairingRequests', {});
    if (response.status !== 200) throw opError('ListEspPairingRequests', response);
    return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
  }

  async approveEspPairing(pairingId, encryptedPayload) {
    const response = await this.ws.request('ApproveEspPairing', {
      pairingId: String(pairingId || '').trim(),
      encryptedPayload: String(encryptedPayload || '').trim(),
    });
    if (response.status !== 200) throw opError('ApproveEspPairing', response);
    return response.payload || {};
  }

  async rejectEspPairing(pairingId, reason = '') {
    const response = await this.ws.request('RejectEspPairing', {
      pairingId: String(pairingId || '').trim(),
      reason: String(reason || '').trim(),
    });
    if (response.status !== 200) throw opError('RejectEspPairing', response);
    return response.payload || {};
  }

  async getEspPairingStatus(pairingId) {
    const response = await this.ws.request('GetEspPairingStatus', {
      pairingId: String(pairingId || '').trim(),
    });
    if (response.status !== 200) throw opError('GetEspPairingStatus', response);
    return response.payload || {};
  }

  async listSubscriptionsFeed(login, limit = 200) {
    const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
    if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
    return response.payload || {};
  }

  async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
    const normalizedChannel = {
      ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
      channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
      channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
    };
    const payload = { channel: normalizedChannel, limit, sort };
    const cleanLogin = String(login || '').trim();
    if (cleanLogin) payload.login = cleanLogin;
    const response = await this.ws.request('GetChannelMessages', payload);
    if (response.status !== 200) throw opError('GetChannelMessages', response);
    return response.payload || {};
  }

  async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
    const normalizedMessage = {
      blockchainName: String(message?.blockchainName || '').trim(),
      blockNumber: Number(message?.blockNumber),
      blockHash: String(message?.blockHash || '').trim(),
    };
    const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
    const cleanLogin = String(login || '').trim();
    if (cleanLogin) payload.login = cleanLogin;
    const response = await this.ws.request('GetMessageThread', payload);
    if (response.status !== 200) throw opError('GetMessageThread', response);
    return response.payload || {};
  }

  async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Missing login for AddBlock');
    if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');

    const resolveFreshCursor = async () => {
      const user = await this.getUser(cleanLogin);
      const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
      const freshNum = Number(user?.serverLastGlobalNumber);
      const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
      return {
        blockchainName,
        cursor: {
          serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
          serverLastGlobalHash: freshHash,
        },
      };
    };
    const freshState = await resolveFreshCursor();
    const blockchainName = freshState.blockchainName;

    const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
    const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
    if (!blockchainPrivatePkcs8) {
      throw new Error('Missing saved blockchain private key on device');
    }

    const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);

    const tryAdd = async (cursor) => {
      const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
      const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
      const preimage = buildBlockPreimage({
        prevBlockHashHex: prevBlockHash,
        blockNumber,
        msgType,
        msgSubType,
        msgVersion,
        bodyBytes,
      });

      const hash32 = await sha256Bytes(preimage);
      const signatureBytes = await signBytes(privateKey, hash32);
      const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);

      return this.ws.request('AddBlock', {
        blockchainName,
        blockNumber,
        prevBlockHash,
        blockBytesB64: bytesToBase64(fullBlock),
      });
    };

    let cursor = freshState.cursor;
    let response = await tryAdd(cursor);
    if (response.status !== 200) {
      const knownNum = Number(response?.payload?.serverLastGlobalNumber);
      const knownHash = String(response?.payload?.serverLastGlobalHash || '');
      if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
        cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
        response = await tryAdd(cursor);
      } else {
        const refreshed = await resolveFreshCursor();
        cursor = refreshed.cursor;
        response = await tryAdd(cursor);
      }
    }

    if (response.status !== 200) throw opError('AddBlock', response);

    const payload = response.payload || {};
    const acceptedNum = Number(payload?.serverLastGlobalNumber);
    const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
    if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
      this.headerHashCache.set(blockchainName, acceptedHash);
    }

    return payload;
  }

  async ensureChainInitializedForLineOps(login, storagePwd) {
    const current = await this.getUser(login);
    const lastNum = Number(current?.serverLastGlobalNumber);
    if (Number.isFinite(lastNum) && lastNum >= 0) return current;
    if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;

    // Bootstrap an empty chain with a minimal USER_PARAM block so line-based
    // channel operations have a valid anchor at block #0.
    await this.addBlockUserParam({
      login,
      storagePwd,
      param: 'shine',
      value: 'yes',
    });

    return this.getUser(login);
  }

  async addBlockLike({ login, message, storagePwd }) {
    const cleanLogin = String(login || '').trim();
    const target = normalizeMessageRefTarget(message, 'like');
    const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;

    return this.runWriteLocked(key, async () => {
      const bodyBytes = makeReactionLikeBodyBytes({
        toBlockchainName: target.blockchainName,
        toBlockNumber: target.blockNumber,
        toBlockHashHex: target.blockHash,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_REACTION,
        msgSubType: MSG_SUBTYPE_REACTION_LIKE,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async addBlockUnlike({ login, message, storagePwd }) {
    const cleanLogin = String(login || '').trim();
    const target = normalizeMessageRefTarget(message, 'unlike');
    const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;

    return this.runWriteLocked(key, async () => {
      const bodyBytes = makeReactionLikeBodyBytes({
        toBlockchainName: target.blockchainName,
        toBlockNumber: target.blockNumber,
        toBlockHashHex: target.blockHash,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_REACTION,
        msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async addBlockReply({ login, message, text, storagePwd }) {
    const cleanLogin = String(login || '').trim();
    const cleanText = String(text || '').trim();
    const target = normalizeMessageRefTarget(message, 'reply');
    const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;

    return this.runWriteLocked(key, async () => {
      const bodyBytes = makeTextReplyBodyBytes({
        toBlockchainName: target.blockchainName,
        toBlockNumber: target.blockNumber,
        toBlockHashHex: target.blockHash,
        text: cleanText,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_TEXT,
        msgSubType: MSG_SUBTYPE_TEXT_REPLY,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async addBlockRepost({ login, channel, message, text, storagePwd }) {
    const cleanLogin = String(login || '').trim();
    if (!cleanLogin) throw new Error('Missing login');
    const cleanText = String(text || '').trim();
    if (!cleanText) throw new Error('Комментарий к репосту обязателен');
    const target = normalizeMessageRefTarget(message, 'repost');
    const selector = channel || {};
    const owner = String(selector?.ownerBlockchainName || '').trim();
    const root = Number(selector?.channelRootBlockNumber);
    const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;

    return this.runWriteLocked(key, async () => {
      const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
      const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
      if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
      if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');

      let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
      if (rootHashHex === ZERO64) {
        const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
        const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
        if (!rootChannel) throw new Error('Channel root not found');
        rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
      }

      let prevLineNumber = root;
      let prevLineHashHex = rootHashHex;
      let thisLineNumber = 0;
      try {
        const latestPayload = await this.getChannelMessages({
          ownerBlockchainName: owner,
          channelRootBlockNumber: root,
          channelRootBlockHash: rootHashHex,
        }, 1, 'desc', cleanLogin);
        const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
        const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
        const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
        const latestLineStep = resolveLatestLineStep(latestMessage);
        if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
          prevLineNumber = latestBlockNumber;
          prevLineHashHex = latestBlockHash;
          thisLineNumber = Number.isFinite(latestLineStep)
            ? Math.max(0, latestLineStep + 1)
            : 1;
        }
      } catch {
        // fallback to root anchor
      }

      const bodyBytes = makeTextRepostBodyBytes({
        lineCode: root,
        prevLineNumber,
        prevLineHashHex,
        thisLineNumber,
        toBlockchainName: target.blockchainName,
        toBlockNumber: target.blockNumber,
        toBlockHashHex: target.blockHash,
        text: cleanText,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_TEXT,
        msgSubType: MSG_SUBTYPE_TEXT_REPOST,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async addBlockEditMessage({
    login,
    message,
    text,
    storagePwd,
    isChannelPost = false,
    channel = null,
  }) {
    const cleanLogin = String(login || '').trim();
    const cleanText = String(text || '').trim();
    const target = normalizeMessageRefTarget(message, 'edit');
    const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;

    return this.runWriteLocked(lockKey, async () => {
      if (isChannelPost) {
        const selector = channel || {};
        const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
        const lineCode = Number(selector?.channelRootBlockNumber);
        if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
          throw new Error('Invalid channel selector for edit');
        }

        let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
        if (rootHashHex === ZERO64) {
          const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
          const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
          if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
        }

        let prevLineNumber = lineCode;
        let prevLineHashHex = rootHashHex;
        let thisLineNumber = 1;
        try {
          const latestPayload = await this.getChannelMessages({
            ownerBlockchainName,
            channelRootBlockNumber: lineCode,
            channelRootBlockHash: rootHashHex,
          }, 1, 'desc', cleanLogin);
          const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
          const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
          const latestVersion = latestVersions[latestVersions.length - 1] || null;
          const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
          const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
          const latestLineStep = resolveLatestLineStep(latestMessage);
          if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
            prevLineNumber = latestBlockNumber;
            prevLineHashHex = latestBlockHash;
            thisLineNumber = Number.isFinite(latestLineStep)
              ? Math.max(0, latestLineStep)
              : 1;
          }
        } catch {
          // fallback to root anchor
        }

        const bodyBytes = makeTextEditPostBodyBytes({
          lineCode,
          prevLineNumber,
          prevLineHashHex,
          thisLineNumber,
          toBlockNumber: target.blockNumber,
          toBlockHashHex: target.blockHash,
          text: cleanText,
        });

        return this.addBlockSigned({
          login: cleanLogin,
          storagePwd,
          msgType: MSG_TYPE_TEXT,
          msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
          msgVersion: 1,
          bodyBytes,
        });
      }

      const bodyBytes = makeTextEditReplyBodyBytes({
        toBlockNumber: target.blockNumber,
        toBlockHashHex: target.blockHash,
        text: cleanText,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_TEXT,
        msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
    const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
    if (!cleanTargetLogin) throw new Error('Target login is required');
    const cleanLogin = String(login || '').trim();
    const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;

    return this.runWriteLocked(key, async () => {
      const targetUser = await this.getUser(cleanTargetLogin);
      if (!targetUser?.exists) throw new Error('Target user not found');
      const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);

      return this.addBlockFollowChannel({
        login: cleanLogin,
        storagePwd,
        targetBlockchainName: targetUser.blockchainName,
        targetBlockNumber: 0,
        targetBlockHashHex: targetHeaderHash,
        unfollow,
      });
    });
  }

  async addBlockFollowChannel({
    login,
    storagePwd,
    targetBlockchainName,
    targetBlockNumber,
    targetBlockHashHex,
    unfollow = false,
  }) {
    const cleanLogin = String(login || '').trim();
    const cleanTargetBch = String(targetBlockchainName || '').trim();
    const cleanTargetBlockNumber = Number(targetBlockNumber);
    if (!cleanTargetBch) throw new Error('Target blockchain is required');
    if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
      throw new Error('Invalid target block number');
    }
    const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
    const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;

    return this.runWriteLocked(key, async () => {
      let targetHashHex = seedHash;
      if (targetHashHex === ZERO64) {
        targetHashHex = cleanTargetBlockNumber === 0
          ? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
          : await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
      }

      const bodyBytes = makeConnectionBodyBytes({
        lineCode: 0,
        prevLineNumber: -1,
        prevLineHashHex: ZERO64,
        thisLineNumber: -1,
        toBlockchainName: cleanTargetBch,
        toBlockNumber: cleanTargetBlockNumber,
        toBlockHashHex: targetHashHex,
      });

      return this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_CONNECTION,
        msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
        msgVersion: 1,
        bodyBytes,
      });
    });
  }

  async getBlockHashByNumber(blockchainName, blockNumber) {
    const cleanBlockNumber = Number(blockNumber);
    try {
      const payload = await this.getMessageThread(
        {
          blockchainName: String(blockchainName || '').trim(),
          blockNumber: cleanBlockNumber,
          blockHash: ZERO64,
        },
        0,
        0,
        1
      );
      const hash = payload?.focus?.messageRef?.blockHash;
      return normalizeHex32(hash, ZERO64);
    } catch (error) {
      if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
        return ZERO64;
      }
      throw error;
    }
  }

  async resolveHeaderHashForBlockchain(blockchainName) {
    const cleanBch = String(blockchainName || '').trim();
    if (!cleanBch) throw new Error('Missing blockchainName');

    if (this.headerHashCache.has(cleanBch)) {
      const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
      if (cached !== ZERO64) return cached;
      this.headerHashCache.delete(cleanBch);
    }

    const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
    if (headerHash !== ZERO64) {
      this.headerHashCache.set(cleanBch, headerHash);
    } else {
      this.headerHashCache.delete(cleanBch);
    }
    return headerHash;
  }

  async listOwnChannelsForBlockchain(login, blockchainName) {
    const feed = await this.listSubscriptionsFeed(login, 500);
    const own = feed?.ownedChannels || [];
    return own
      .filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
      .map((item) => ({
        rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
        rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
        channelName: String(item?.channel?.channelName || ''),
        channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
      }))
      .filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
  }

  async addBlockCreateChannel({
    login,
    channelName,
    channelDescription = '',
    channelType = CHANNEL_TYPE_PUBLIC,
    channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
    storagePwd,
  }) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Missing login');

    const typeCode = Number(channelType);
    const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
      ? validatePersonalChannelName(channelName)
      : validateChannelDisplayName(channelName);
    if (!nameCheck.ok) {
      throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
    }
    const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
    const cleanChannelDescription = normalizeChannelDescription(channelDescription);
    const channelSlug = toCanonicalChannelSlug(cleanChannelName);
    const typeVersion = Number(channelTypeVersion);
    const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
    return this.runWriteLocked(key, async () => {
      const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
      const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
      const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
      const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);

      const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
      const createdChannels = ownChannels
        .filter((item) => item.rootBlockNumber > 0)
        .sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);

      let prevLineNumber = 0;
      let prevLineHashHex = (
        Number.isFinite(userLastGlobalNumber) &&
        userLastGlobalNumber === 0 &&
        userLastGlobalHash !== ZERO64
      )
        ? userLastGlobalHash
        : await this.resolveHeaderHashForBlockchain(blockchainName);
      let thisLineNumber = 1;

      if (createdChannels.length > 0) {
        const last = createdChannels[createdChannels.length - 1];
        prevLineNumber = last.rootBlockNumber;
        prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
        thisLineNumber = createdChannels.length + 1;
      }

      let payload;
      try {
        payload = await this.addBlockSigned({
          login: cleanLogin,
          storagePwd,
          msgType: MSG_TYPE_TECH,
          msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
          msgVersion: CREATE_CHANNEL_BODY_VERSION,
          bodyBytes: makeCreateChannelBodyBytes({
            lineCode: 0,
            prevLineNumber,
            prevLineHashHex,
            thisLineNumber,
            channelName: cleanChannelName,
            channelDescription: cleanChannelDescription,
            channelType: typeCode,
            channelTypeVersion: typeVersion,
          }),
        });
      } catch (error) {
        const rawCode = String(error?.code || '').toUpperCase();
        const rawText = String(error?.message || '').toLowerCase();
        const isLegacyFormatMismatch = (
          rawCode === 'BAD_BLOCK_FORMAT' ||
          rawText.includes('bad_block_format') ||
          rawText.includes('некорректный формат блока')
        );
        if (!isLegacyFormatMismatch) throw error;

        // Совместимость со старыми серверами, где CreateChannel body без description/type.
        payload = await this.addBlockSigned({
          login: cleanLogin,
          storagePwd,
          msgType: MSG_TYPE_TECH,
          msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
          msgVersion: 1,
          bodyBytes: makeCreateChannelBodyBytesLegacy({
            lineCode: 0,
            prevLineNumber,
            prevLineHashHex,
            thisLineNumber,
            channelName: cleanChannelName,
          }),
        });
      }

      const selector = {
        ownerBlockchainName: blockchainName,
        channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
        channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
      };

      return {
        ...payload,
        channel: {
          ...selector,
        },
      };
    });
  }

  async addBlockTextPost({ login, channel, text, storagePwd }) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Missing login');
    const cleanText = String(text || '').trim();
    const selector = channel || {};
    const owner = String(selector?.ownerBlockchainName || '').trim();
    const root = Number(selector?.channelRootBlockNumber);
    const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;

    return this.runWriteLocked(key, async () => {
      const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
      const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();

      const ownerBlockchainName = owner;
      const lineCode = root;
      if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
        throw new Error('Invalid channel selector');
      }
      if (ownerBlockchainName !== blockchainName) {
        throw new Error('Posting is allowed only to your own channels');
      }

      let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
      if (rootHashHex === ZERO64) {
        const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
        const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
        if (!rootChannel) throw new Error('Channel root not found');
        rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
      }

      let prevLineNumber = lineCode;
      let prevLineHashHex = rootHashHex;
      let thisLineNumber = 0;
      try {
        const latestPayload = await this.getChannelMessages({
          ownerBlockchainName,
          channelRootBlockNumber: lineCode,
          channelRootBlockHash: rootHashHex,
        }, 1, 'desc', cleanLogin);
        const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
        const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
        const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
        const latestLineStep = resolveLatestLineStep(latestMessage);
        if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
          prevLineNumber = latestBlockNumber;
          prevLineHashHex = latestBlockHash;
          // Для нового POST берём следующий шаг после последнего сообщения линии.
          thisLineNumber = Number.isFinite(latestLineStep)
            ? Math.max(0, latestLineStep + 1)
            : 1;
        }
      } catch {
        // fallback to root anchor
      }

      const bodyBytes = makeTextPostBodyBytes({
        lineCode,
        prevLineNumber,
        prevLineHashHex,
        thisLineNumber,
        text: cleanText,
      });

      const payload = await this.addBlockSigned({
        login: cleanLogin,
        storagePwd,
        msgType: MSG_TYPE_TEXT,
        msgSubType: MSG_SUBTYPE_TEXT_POST,
        msgVersion: 1,
        bodyBytes,
      });

      return {
        ...payload,
        channel: {
          ownerBlockchainName,
          channelRootBlockNumber: lineCode,
          channelRootBlockHash: rootHashHex,
        },
      };
    });
  }


  onEvent(op, handler) {
    return this.ws.onEvent(op, handler);
  }

  async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
    const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
    if (response.status !== 200) throw opError('UpsertPushToken', response);
    return response.payload || {};
  }

  async sendTestWebPush({ login = '', sessionId = '', title = '', text = '' } = {}) {
    const payload = {};
    if (String(login || '').trim()) payload.login = String(login || '').trim();
    if (String(sessionId || '').trim()) payload.sessionId = String(sessionId || '').trim();
    if (String(title || '').trim()) payload.title = String(title || '').trim();
    if (String(text || '').trim()) payload.text = String(text || '').trim();
    const response = await this.ws.request('SendTestWebPush', payload);
    if (response.status !== 200) throw opError('SendTestWebPush', response);
    return response.payload || {};
  }

  async buildSignedDm2Block({
    login,
    toLogin,
    storagePwd,
    timeMs,
    nonce,
    messageType,
    payloadBytes,
  }) {
    const cleanFromLogin = String(login || '').trim();
    const cleanToLogin = String(toLogin || '').trim();
    if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
    if (!storagePwd) throw new Error('Не передан storagePwd для подписи');

    const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
    const devicePriv = secrets?.deviceKey;
    if (!devicePriv) throw new Error('Не найден приватный deviceKey');
    const privateKey = await importPkcs8Ed25519(devicePriv);

    const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
    const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
    if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
      throw new Error('payload должен быть 1..4096 байт');
    }

    const preimage = concatBytes(
      DM2_PREFIX,
      uint8Bytes(toBytes.length), toBytes,
      uint8Bytes(fromBytes.length), fromBytes,
      uint64Bytes(timeMs),
      uint32Bytes(nonce),
      uint16Bytes(messageType),
      uint16Bytes(payloadBytes.length),
      payloadBytes,
    );
    const signature = await signBytes(privateKey, preimage);
    return concatBytes(preimage, signature);
  }

  parseSignedMessageBlob(blobB64) {
    const bytes = base64ToBytes(String(blobB64 || '').trim());
    return parseSignedMessageBlockBytes(bytes);
  }

  parseReadReceiptPayload(payloadBytes) {
    if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
    let o = 0;
    const read = (n) => {
      if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
      const out = payloadBytes.slice(o, o + n);
      o += n;
      return out;
    };
    const readU8 = () => read(1)[0];
    const readU16 = () => {
      const part = read(2);
      return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
    };
    const readU32 = () => {
      const part = read(4);
      return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
    };
    const readU64 = () => {
      const part = read(8);
      return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false));
    };
    const readAscii = () => {
      const len = readU8();
      const part = read(len);
      return new TextDecoder().decode(part);
    };
    const refToLogin = readAscii();
    const refFromLogin = readAscii();
    const refTimeMs = readU64();
    const refNonce = readU32();
    const refType = readU16();
    if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
    return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
  }

  async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
    const body = { incomingBlobB64, outgoingBlobB64 };
    const primaryOp = 'ReceiveOutcomingMessage';
    let response = await this.ws.request(primaryOp, body);
    if (response.status === 404) {
      response = await this.ws.request('SendMessagePair', body);
      if (response.status !== 200) throw opError('SendMessagePair', response);
      return response.payload || {};
    }
    if (response.status !== 200) throw opError(primaryOp, response);
    return response.payload || {};
  }

  async sendDirectMessage({ login, toLogin, text, storagePwd }) {
    const cleanFromLogin = String(login || '').trim();
    const cleanToLogin = String(toLogin || '').trim();
    const cleanText = String(text || '');
    if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');

    const timeMs = Date.now();
    const nonce = Math.floor(Math.random() * 0x100000000);
    const incomingPayload = utf8Bytes(cleanText);
    const outgoingPayload = utf8Bytes(cleanText);

    const incomingBlock = await this.buildSignedDm2Block({
      login: cleanFromLogin,
      toLogin: cleanToLogin,
      storagePwd,
      timeMs,
      nonce,
      messageType: DM2_TYPE_INCOMING,
      payloadBytes: incomingPayload,
    });
    const outgoingBlock = await this.buildSignedDm2Block({
      login: cleanFromLogin,
      toLogin: cleanToLogin,
      storagePwd,
      timeMs,
      nonce,
      messageType: DM2_TYPE_OUTGOING_COPY,
      payloadBytes: outgoingPayload,
    });

    const payload = await this.sendMessagePair({
      incomingBlobB64: bytesToBase64(incomingBlock),
      outgoingBlobB64: bytesToBase64(outgoingBlock),
    });
    return {
      ...payload,
      localIncomingBlobB64: bytesToBase64(incomingBlock),
      localOutgoingBlobB64: bytesToBase64(outgoingBlock),
      localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs, nonce }),
    };
  }

  async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
    const timeMs = Date.now();
    const nonce = Math.floor(Math.random() * 0x100000000);
    const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });

    const type3 = await this.buildSignedDm2Block({
      login,
      toLogin,
      storagePwd,
      timeMs,
      nonce,
      messageType: DM2_TYPE_READ_INCOMING,
      payloadBytes: payload,
    });
    const type4 = await this.buildSignedDm2Block({
      login,
      toLogin,
      storagePwd,
      timeMs,
      nonce,
      messageType: DM2_TYPE_READ_OUTGOING_COPY,
      payloadBytes: payload,
    });
    return this.sendMessagePair({
      incomingBlobB64: bytesToBase64(type3),
      outgoingBlobB64: bytesToBase64(type4),
    });
  }

  async ackSessionDelivery(messageKey) {
    const response = await this.ws.request('AckSessionDelivery', { messageKey });
    if (response.status !== 200) throw opError('AckSessionDelivery', response);
    return response.payload || {};
  }


  async callInviteBroadcast({ toLogin, callId, type = 100 }) {
    const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
    if (response.status !== 200) throw opError('CallInviteBroadcast', response);
    return response.payload || {};
  }

  async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
    const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data });
    if (response.status !== 200) throw opError('CallSignalToSession', response);
    return response.payload || {};
  }

  async getCallIceConfig() {
    const response = await this.ws.request('GetCallIceConfig', {});
    if (response.status !== 200) throw opError('GetCallIceConfig', response);
    return response.payload || {};
  }

  async sendCallDeliveryReport(payload = {}) {
    const response = await this.ws.request('CallDeliveryReport', payload);
    if (response.status !== 200) throw opError('CallDeliveryReport', response);
    return response.payload || {};
  }

  async listContacts() {
    const response = await this.ws.request('ListContacts', {});
    if (response.status !== 200) throw opError('ListContacts', response);
    return response.payload || {};
  }


  async addCloseFriend(toLogin) {
    const response = await this.ws.request('AddCloseFriend', { toLogin });
    if (response.status !== 200) throw opError('AddCloseFriend', response);
    return response.payload || {};
  }

  async getUserConnectionsGraph(login) {
    const response = await this.ws.request('GetUserConnectionsGraph', { login });
    if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
    return response.payload || {};
  }

  async searchUsers(prefix) {
    const response = await this.ws.request('SearchUsers', { prefix });
    if (response.status !== 200) throw opError('SearchUsers', response);
    return response.payload?.logins || [];
  }



  async getUserParam(login, param) {
    const cleanLogin = (login || '').trim();
    const cleanParam = (param || '').trim();
    if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param');

    const response = await this.ws.request('GetUserParam', { login: cleanLogin, param: cleanParam });
    if (response.status === 200) return response.payload || {};

    if (response.status === 404 || response.status === 204) return {};

    throw opError('GetUserParam', response);
  }

  async listUserParams(login) {
    const cleanLogin = (login || '').trim();
    if (!cleanLogin) throw new Error('Не передан login');

    const response = await this.ws.request('ListUserParams', { login: cleanLogin });
    if (response.status !== 200) throw opError('ListUserParams', response);
    return response.payload || {};
  }

  async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
    const cleanKind = String(kind || '').trim().toLowerCase();
    const kinds = CONNECTION_SUBTYPES[cleanKind];
    if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
    const subType = enabled ? kinds.on : kinds.off;
    return this.addBlockConnection({ login, toLogin, subType, storagePwd });
  }

  async addBlockUserParam({ login, param, value, storagePwd }) {
    const cleanLogin = (login || '').trim();
    const cleanParam = (param || '').trim();
    const cleanValue = String(value ?? '').trim();
    if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
    if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
    if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');

    const user = await this.getUser(cleanLogin);
    const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
    const freshNum = Number(user?.serverLastGlobalNumber);
    const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
    const freshCursor = {
      serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
      serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
    };

    const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
    const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
    if (!blockchainPrivatePkcs8) {
      throw new Error('На устройстве нет сохраненного приватного blockchainKey');
    }

    const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);

    const tryAdd = async (cursor) => {
      const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
      const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);

      // Для USER_PARAM отправляем старт новой line-цепочки:
      // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
      // Этот формат соответствует BodyHasLine правилам на сервере.
      const bodyBytes = makeUserParamBodyBytes({
        lineCode: 0,
        prevLineNumber: -1,
        prevLineHashHex: ZERO_HASH_HEX,
        thisLineNumber: -1,
        key: cleanParam,
        value: cleanValue,
      });

      const preimage = concatBytes(
        int16Bytes(0),
        hexToBytes(prevBlockHash),
        int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
        int32Bytes(blockNumber),
        int64Bytes(Math.floor(Date.now() / 1000)),
        int16Bytes(4),
        int16Bytes(1),
        int16Bytes(1),
        bodyBytes,
      );

      const hash32 = await sha256Bytes(preimage);
      const signatureBytes = await signBytes(privateKey, hash32);
      const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);

      const response = await this.ws.request('AddBlock', {
        blockchainName,
        blockNumber,
        prevBlockHash,
        blockBytesB64: bytesToBase64(fullBlock),
      });

      return response;
    };

    let cursor = freshCursor;
    let response = await tryAdd(cursor);
    if (response.status !== 200) {
      const knownNum = Number(response?.payload?.serverLastGlobalNumber);
      const knownHash = String(response?.payload?.serverLastGlobalHash || '');
      if (Number.isFinite(knownNum) && knownHash.length === 64) {
        cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
        response = await tryAdd(cursor);
      }
    }

    if (response.status !== 200) throw opError('AddBlock', response);
    return response.payload || {};
  }

  async addBlockConnection({ login, toLogin, subType, storagePwd }) {
    const cleanLogin = (login || '').trim();
    const cleanToLogin = (toLogin || '').trim();
    const cleanSubType = Number(subType);
    if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
    if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
    if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
    if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
      throw new Error('Нельзя создать связь на самого себя.');
    }

    const user = await this.getUser(cleanLogin);
    if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
    const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
    const freshNum = Number(user?.serverLastGlobalNumber);
    const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
    const freshCursor = {
      serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
      serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
    };

    const targetUser = await this.getUser(cleanToLogin);
    if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
    const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();

    const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
    const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
    if (!blockchainPrivatePkcs8) {
      throw new Error('На устройстве нет сохраненного приватного blockchainKey');
    }
    const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);

    const tryAdd = async (cursor) => {
      const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
      const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);

      // Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
      // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
      // target для user-связей указывает на HEADER пользователя (blockNumber=0).
      const bodyBytes = makeConnectionBodyBytes({
        lineCode: 0,
        prevLineNumber: -1,
        prevLineHashHex: ZERO_HASH_HEX,
        thisLineNumber: -1,
        toBlockchainName,
        toBlockNumber: 0,
        toBlockHashHex: ZERO_HASH_HEX,
      });

      const preimage = concatBytes(
        int16Bytes(0),
        hexToBytes(prevBlockHash),
        int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
        int32Bytes(blockNumber),
        int64Bytes(Math.floor(Date.now() / 1000)),
        int16Bytes(3),
        int16Bytes(cleanSubType),
        int16Bytes(1),
        bodyBytes,
      );

      const hash32 = await sha256Bytes(preimage);
      const signatureBytes = await signBytes(privateKey, hash32);
      const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);

      return this.ws.request('AddBlock', {
        blockchainName,
        blockNumber,
        prevBlockHash,
        blockBytesB64: bytesToBase64(fullBlock),
      });
    };

    let cursor = freshCursor;
    let response = await tryAdd(cursor);
    if (response.status !== 200) {
      const knownNum = Number(response?.payload?.serverLastGlobalNumber);
      const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
      if (Number.isFinite(knownNum) && knownHash.length === 64) {
        cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
        response = await tryAdd(cursor);
      }
    }

    if (response.status !== 200) throw opError('AddBlock', response);
    return response.payload || {};
  }


  async reportClientDebug({ runId = '', level = 'info', message = '', details = '' } = {}) {
    try {
      const response = await this.ws.request('ClientDebugLog', { runId, level, message, details }, 3000);
      return response?.status === 200;
    } catch {
      return false;
    }
  }

  async reportClientError(details) {
    try {
      const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
      return response?.status === 200;
    } catch {
      return false;
    }
  }

  async reportClientUiError(details = {}) {
    try {
      const payload = {
        source: 'ui_error',
        code: 'UI_RUNTIME_ERROR',
        ...details,
      };
      const response = await this.sendCallDeliveryReport({
        type: 'ui_error',
        value: JSON.stringify(payload),
      });
      return !!response;
    } catch {
      return false;
    }
  }

  close() {
    this.ws.close();
  }
}
