 "use strict";

 // Сховище reusable-токена реконекту: розбір часу СЕРВЕРА, шифрування, стан операції видачі.

 const Service = require('./service.js');
 const console = require('gifer-console');
 const crypto  = require('crypto');

 const ENVELOPE_VERSION = "v1";
 const PBKDF2_ROUNDS    = 100000;
 const KEY_LEN          = 32;
 const SALT_LEN         = 16;
 const IV_LEN           = 12;
 const TAG_LEN          = 16;

 // safeStorage приходить явною ін'єкцією (тест підміняє його без Electron).
 // require('electron') — лише production-дефолт ПІСЛЯ точки ін'єкції: у plain-node він кине MODULE_NOT_FOUND.
 let _safeStorage;
 let _mode;

 // ==================================================
 // Час

 // "dd.mm.yyyy.hh.nn.ss" або "dd.mm.yyyy.hh.nn.ss.zzz" → Date у UTC.
 // Сміттєвий рядок → null (не Invalid Date).
 function parseServerDT(str){

     if (typeof str !== 'string') return null;

     let p = str.split('.');

     if (p.length < 6) return null;

     let n = [];

     for (let i = 0; i < 6; i++){
         n[i] = parseInt(p[i], 10);

         if (isNaN(n[i])) return null;
     }

     let ms = p.length > 6 ? parseInt(p[6], 10) : 0;

     if (isNaN(ms)) ms = 0;

     let date = new Date(Date.UTC(n[2], n[1] - 1, n[0], n[3], n[4], n[5], ms));

     if (isNaN(date.getTime())) return null;

     // Date.UTC мовчки перекочує 31.02 у 03.03 — звіряємо компоненти назад, інакше зіпсований
     // CurrentDT дасть правдоподібний, але неправильний Expired
     if (date.getUTCFullYear() !== n[2] || date.getUTCMonth() !== n[1] - 1 || date.getUTCDate() !== n[0] ||
         date.getUTCHours()    !== n[3] || date.getUTCMinutes() !== n[4]   || date.getUTCSeconds() !== n[5]){
         return null;
     }

     return date;
 }

 // Тільки UTC: Expired живе за годинником СЕРВЕРА, не клієнта
 function expiresFrom(currentDT, days){
     let from = parseServerDT(currentDT);

     if (!from) return "";

     from.setUTCDate(from.getUTCDate() + days);

     return Service.formatDate(from, 'dd.mm.yyyy.hh.nn.ss', true);
 }

 // ==================================================
 // Режим шифрування

 function getSafeStorage(){

     if (_safeStorage === undefined){
         try {
             _safeStorage = require('electron').safeStorage || null;
         } catch (e){
             _safeStorage = null;
         }
     }

     return _safeStorage;
 }

 // getSelectedStorageBackend() існує лише на Linux — безумовний виклик на Windows/macOS дає TypeError
 function isSafeAvailable(){
     let safeStorage = getSafeStorage();

     if (!safeStorage || !safeStorage.isEncryptionAvailable()) return false;

     return process.platform !== 'linux' ||
            ['basic_text', 'unknown'].indexOf(safeStorage.getSelectedStorageBackend()) === -1;
 }

 // Обраний режим мемоізується разом із логом: інакше рядок дублюється на кожен запис
 function getMode(){

     if (_mode) return _mode;

     let safeStorage = getSafeStorage();
     let available   = !!(safeStorage && safeStorage.isEncryptionAvailable());
     let backend     = (available && process.platform === 'linux') ? safeStorage.getSelectedStorageBackend() : "";

     _mode = isSafeAvailable() ? 'safe' : 'hwid';

     // Пишемо саме остаточний режим: на Linux із basic_text прапорець істинний, а режим — hwid
     console.log("(tokenStore) platform: " + process.platform +
                 ", isEncryptionAvailable: " + available +
                 (backend ? ", backend: " + backend : "") +
                 ", mode: " + _mode, console.logLevel.INFO);

     return _mode;
 }

 // ==================================================
 // Крипта

 // Своя крипта, а не Service.Encrypt: там константний ключ і константний IV,
 // тобто однаковий секрет дає однаковий шифротекст.
 function deriveKey(salt){
     let material = String(global.myHWND || "");

     if (!material) throw new Error("(tokenStore) no hardware id for key derivation");

     return crypto.pbkdf2Sync(material, salt, PBKDF2_ROUNDS, KEY_LEN, 'sha256');
 }

 function encryptHWID(plain){
     let salt   = crypto.randomBytes(SALT_LEN);
     let iv     = crypto.randomBytes(IV_LEN);
     let cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(salt), iv);
     let ct     = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);

     return [ENVELOPE_VERSION, salt.toString('base64'), iv.toString('base64'), cipher.getAuthTag().toString('base64'), ct.toString('base64')].join('|');
 }

 function decryptHWID(envelope){
     let parts = String(envelope).split('|');

     if (parts.length !== 5 || parts[0] !== ENVELOPE_VERSION) throw new Error("(tokenStore) bad envelope");

     let salt = Buffer.from(parts[1], 'base64');
     let iv   = Buffer.from(parts[2], 'base64');
     let tag  = Buffer.from(parts[3], 'base64');
     let ct   = Buffer.from(parts[4], 'base64');

     // Довжину тега перевіряємо явно: Node приймає GCM-тег від 4 байт, тобто підміна самого тега
     // зводить цілісність зі 128 біт до 32
     if (salt.length !== SALT_LEN || iv.length !== IV_LEN || tag.length !== TAG_LEN) throw new Error("(tokenStore) bad envelope");

     let decipher = crypto.createDecipheriv('aes-256-gcm', deriveKey(salt), iv, { authTagLength: TAG_LEN });

     decipher.setAuthTag(tag);

     return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
 }

 // Повертає { enc, data }: маркер режиму лягає в колонку enc і звіряється при читанні
 function encrypt(plain){
     let mode = getMode();

     if (mode === 'safe'){
         return { enc: 'safe', data: getSafeStorage().encryptString(plain).toString('base64') };
     }

     return { enc: 'hwid', data: encryptHWID(plain) };
 }

 // Розбіжність маркера з доступним зараз режимом — відмова, а не спроба розшифрувати
 function decrypt(data, enc){
     let mode = getMode();

     if (enc !== mode) throw new Error("(tokenStore) enc mode mismatch: stored '" + enc + "', current '" + mode + "'");

     if (mode === 'safe'){
         return getSafeStorage().decryptString(Buffer.from(String(data), 'base64'));
     }

     return decryptHWID(data);
 }

 // ==================================================
 // Sentinel пароля

 // Живе тільки в пам'яті й тільки тут: electronCMD.js — фабрика, її __ недосяжний для mcconnect.js,
 // який electronCMD не тягне і тягнути не може. tokenStore вимагають обидва споживачі.
 const PWD_SENTINEL = Service.RandomHash(50);

 function getSentinel(){
     return PWD_SENTINEL;
 }

 // ==================================================
 // Операція видачі

 // Одна на процес, не на з'єднання: reconnect створює нове ClientConnect, і прив'язана до
 // з'єднання позначка померла б разом зі старим сокетом, дозволивши друге замовлення.
 // connection тут — поле для ЗВІРКИ, щоб запізніла відповідь на старе з'єднання не потрапила
 // в нову операцію і не записалась із чужим expired.
 let pending = null;
 let opSeq   = 0;

 const ISSUE_TIMEOUT = 30000;

 // Лічильник послідовних невдач видачі. Без нього постійний збій (сервер не приймає параметри,
 // диск недоступний) дає замовлення на КОЖНОМУ вході й reconnect — а якщо сервер токен усе ж
 // створює й падає лише наш запис, на сервері накопичуються сироти, яких нічим відкликати.
 // Здаємось до перезапуску процесу: наступний старт спробує знову.
 let failStreak = 0;

 const MAX_ISSUE_FAILURES = 3;

 function noteFailure(reason){
     failStreak++;

     console.warn("(token) issue failed (" + failStreak + "/" + MAX_ISSUE_FAILURES + "): " + reason);

     if (failStreak === MAX_ISSUE_FAILURES){
         console.warn("(token) too many failed issues, no more orders until client restart");
     }
 }

 function noteSuccess(){
     failStreak = 0;
 }

 function isGivenUp(){
     return failStreak >= MAX_ISSUE_FAILURES;
 }

 function samePair(op, servId, uin){
     return op.servId === servId && String(op.uin) === String(uin);
 }

 function begin(connection, servId, uin, expired){
     let id = ++opSeq;

     pending = {
         id        : id,
         connection: connection,
         servId    : servId,
         uin       : uin,
         expired   : expired,
         state     : 'awaiting',
         timer     : null
     };

     // Колбек однаково звіряє id і стан: між постановкою й спрацюванням операція могла двічі
     // змінитись, а знята в committing операція дала б dropAll() поперед set()
     pending.timer = setTimeout(function () {
         if (pending && pending.id === id && pending.state === 'awaiting'){
             pending = null;

             noteFailure("order #" + id + " timed out after " + (ISSUE_TIMEOUT / 1000) + "s");
         }
     }, ISSUE_TIMEOUT);

     return pending;
 }

 // Приймається лише за трьома умовами разом: awaiting, те саме з'єднання, та сама пара
 function accept(connection, servId, uin){

     if (!pending || pending.state !== 'awaiting') return false;

     if (pending.connection !== connection || !samePair(pending, servId, uin)) return false;

     clearTimeout(pending.timer);

     pending.timer = null;
     pending.state = 'committing';

     return pending;
 }

 // Скасована в committing операція не скасовує сам запис — писар після нього робить фінальний delete
 function cancel(){

     if (!pending) return;

     clearTimeout(pending.timer);

     pending.timer = null;

     if (pending.state === 'committing'){
         pending.state = 'cancelled';
     } else {
         pending = null;
     }
 }

 // Скасування чіпляється до факту «увійшла ІНША пара», а не до самого виклику чистки:
 // dropOther іде на кожному sc_login, зокрема на reconnect тією ж парою
 function cancelIfOther(servId, uin){
     if (pending && !samePair(pending, servId, uin)) cancel();
 }

 // CloseConnection кличе onDisconnect без аргументів і спрацьовує двічі (closeOnError → end →
 // 'close'), тож без звірки власника запізнілий колбек від старого сокета зняв би операцію нового
 function cancelForConnection(connection){
     if (pending && pending.connection === connection) cancel();
 }

 function isAwaiting(){
     return !!pending && pending.state === 'awaiting';
 }

 // Для реюз-перевірки: у committing рядка в БД ще немає, але токен уже наш — друге замовлення зайве
 function isBusy(){
     return !!pending && pending.state !== 'cancelled';
 }

 function isCancelled(op){
     return !!op && op.state === 'cancelled';
 }

 // Звільнити операцію після завершення запису; чужу (уже замінену новою) не чіпаємо
 function release(op){
     if (pending && op && pending.id === op.id) pending = null;
 }

 // ==================================================

 exports.getSentinel         = getSentinel;
 exports.begin               = begin;
 exports.accept              = accept;
 exports.cancel              = cancel;
 exports.cancelIfOther       = cancelIfOther;
 exports.cancelForConnection = cancelForConnection;
 exports.isAwaiting          = isAwaiting;
 exports.isBusy              = isBusy;
 exports.noteFailure         = noteFailure;
 exports.noteSuccess         = noteSuccess;
 exports.isGivenUp           = isGivenUp;
 exports.isCancelled         = isCancelled;
 exports.release             = release;

 exports.parseServerDT  = parseServerDT;
 exports.expiresFrom    = expiresFrom;
 exports.encrypt        = encrypt;
 exports.decrypt        = decrypt;
 exports.getMode        = getMode;
 exports.setSafeStorage = function (safeStorage){ _safeStorage = safeStorage || null; _mode = undefined; };
