Загрузка данных


// ==UserScript==
// @name         Fuel QR Helper MAX Web - reviewed
// @version      10.2-reviewed
// @match        https://web.max.ru/*
// @match        https://fuel.sevtech.org/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const DEFAULT_PLATES = [
    'А777АА92',
    'А777АА82'
  ];

  // Prefer stable code first, then id/title fallback.
  const FUEL_PRIORITY = [
    { codes: ['a92'], id: 3, title: /92/ },
    { codes: ['a95'], id: 4, title: /95(?!.*\+|.*plus|.*ultra|.*ультра)/i },
    { codes: ['a95_plus', 'a95_ultra'], id: 5, title: /95.*(\+|plus|ultra|ультра)|(?:\+|plus|ultra|ультра).*95/i }
  ];

  const SESSION_KEY = 'fuelQrSessionUntil';
  const PLATES_KEY = 'fuelQrHelperPlates';
  const HARD_MODE_KEY = 'fuelQrHelperHardMode';
  const AUTH_MODE = new URLSearchParams(window.location.search).get('mode') || 'max-mini-app';
  const DEFAULT_POLL_MS = 1000;
  const HARD_POLL_MS = 250;
  const HARD_WINDOW_MS = 30000;
  const CREATE_FAIL_COOLDOWN_MS = 250;
  const MAX_POLL_MS = 10000;
  const BACKOFF = 1.7;
  const DEBUG = true;

  let stopped = false;
  let paused = false;
  let busy = false;
  let index = 0;
  let timer = null;
  let pollMs = DEFAULT_POLL_MS;
  let sessionProbeInFlight = false;
  let lastSessionProbeAt = 0;
  let lastStatusText = '';
  let lastStatusLogAt = 0;
  let hardModeEnabled = loadBool(HARD_MODE_KEY, true);
  let hardModeUntil = 0;
  let lastHardAlertAt = 0;
  let fuelAttemptIndex = 0;

  const DONE = new Set();
  const SKIPPED = new Set();
  let PLATES = loadPlates();

  if (location.hostname === 'web.max.ru') {
    console.log('[FUEL QR] MAX Web открыт. Рабочая часть запускается внутри iframe fuel.sevtech.org.');
    return;
  }

  if (location.hostname !== 'fuel.sevtech.org') return;

  class ApiError extends Error {
    constructor(message, data, status) {
      super(message);
      this.name = 'ApiError';
      this.data = data || {};
      this.status = status || 0;
      this.path = '';
      this.code = this.data.error_code || this.data.errorCode || this.data.code || '';
      this.sessionExpired = this.status === 401 || this.code === 'session_expired';
    }
  }

  function log(...args) {
    console.log('%c[FUEL QR]', 'background:#063;color:#fff;padding:2px 6px;border-radius:4px', ...args);
  }

  function debug(...args) {
    if (!DEBUG) return;
    console.debug('%c[FUEL QR DEBUG]', 'background:#123;color:#9ff;padding:2px 6px;border-radius:4px', ...args);
  }

  function warn(...args) {
    console.warn('%c[FUEL QR WARN]', 'background:#421;color:#ffd;padding:2px 6px;border-radius:4px', ...args);
  }

  function safeJson(value) {
    try {
      return JSON.parse(JSON.stringify(value, (key, val) => {
        if (/hash|init_data|phone|token|contact|photo|session_public_id/i.test(key)) return '[redacted]';
        return val;
      }));
    } catch (e) {
      return value;
    }
  }

  function sessionLeft() {
    const until = parseInt(localStorage.getItem(SESSION_KEY) || '0', 10);
    return Math.max(0, Math.floor((until - Date.now()) / 1000));
  }

  function hasSession() {
    return sessionLeft() > 5;
  }

  function loadPlates() {
    try {
      const saved = JSON.parse(localStorage.getItem(PLATES_KEY) || '[]');
      if (Array.isArray(saved) && saved.length) {
        return saved.map(normalizePlate).filter(x => x.length >= 7);
      }
    } catch (e) {
      // Ignore broken saved settings.
    }
    return DEFAULT_PLATES.map(normalizePlate).filter(x => x.length >= 7);
  }

  function savePlates(values) {
    const next = Array.from(new Set(values.map(normalizePlate).filter(x => x.length >= 7)));
    if (!next.length) return false;
    PLATES = next;
    index = 0;
    try {
      localStorage.setItem(PLATES_KEY, JSON.stringify(PLATES));
    } catch (e) {
      // localStorage can be unavailable in embedded webviews.
    }
    return true;
  }

  function loadBool(key, defaultValue) {
    try {
      const saved = localStorage.getItem(key);
      if (saved === '1') return true;
      if (saved === '0') return false;
    } catch (e) {
      // localStorage can be unavailable in embedded webviews.
    }
    return defaultValue;
  }

  function saveBool(key, value) {
    try {
      localStorage.setItem(key, value ? '1' : '0');
    } catch (e) {
      // localStorage can be unavailable in embedded webviews.
    }
  }

  function markSessionActive(ttl) {
    const seconds = parseInt(ttl, 10);
    if (!Number.isFinite(seconds) || seconds <= 0) return;
    try {
      localStorage.setItem(SESSION_KEY, String(Date.now() + seconds * 1000));
    } catch (e) {
      // localStorage can be unavailable in embedded webviews.
    }
  }

  async function ensureSession() {
    if (hasSession()) return true;
    if (sessionProbeInFlight) return false;
    if (Date.now() - lastSessionProbeAt < 3000) return false;

    sessionProbeInFlight = true;
    lastSessionProbeAt = Date.now();
    try {
      const data = await api('/session/status', null, 5000);
      if (data && data.auth_mode && data.auth_mode !== AUTH_MODE) {
        warn('session/status:auth-mode-mismatch', { server: data.auth_mode, client: AUTH_MODE });
        return false;
      }

      if (data && data.active !== false && data.ttl) {
        debug('session/status:active', { authMode: data.auth_mode || '', ttl: data.ttl, sessionPublicId: '[redacted]' });
        markSessionActive(data.ttl);
        return hasSession();
      }
    } catch (e) {
      if (!e.sessionExpired) {
        log('session/status probe failed:', e.message || e);
      }
    } finally {
      sessionProbeInFlight = false;
    }
    return false;
  }

  function normalizePlate(value) {
    const latin = 'ABEKMHOPCTYX';
    const cyr = 'АВЕКМНОРСТУХ';

    return String(value || '')
      .trim()
      .toUpperCase()
      .split('')
      .map(ch => {
        const i = latin.indexOf(ch);
        return i >= 0 ? cyr[i] : ch;
      })
      .join('')
      .replace(/[^0-9A-ZА-ЯЁ]/g, '');
  }

  function isStandardPlate(value) {
    return /^[АВЕКМНОРСТУХ][0-9]{3}[АВЕКМНОРСТУХ]{2}[0-9]{2,3}$/.test(value || '');
  }

  function plateFormatConfirmed(value) {
    return !isStandardPlate(value);
  }

  function getUiPlate() {
    const input = document.querySelector('[data-plate-input]');
    return input && input.value ? normalizePlate(input.value) : '';
  }

  function currentPlate() {
    const uiPlate = getUiPlate();
    if (uiPlate && uiPlate.length >= 7) return uiPlate;
    return normalizePlate(PLATES[index % PLATES.length]);
  }

  function hasTargetsLeft() {
    const uiPlate = getUiPlate();
    if (uiPlate && uiPlate.length >= 7) {
      return !DONE.has(uiPlate) && !SKIPPED.has(uiPlate);
    }

    return PLATES.some(x => {
      const p = normalizePlate(x);
      return !DONE.has(p) && !SKIPPED.has(p);
    });
  }

  function moveToNextTarget() {
    if (getUiPlate()) return currentPlate();

    for (let i = 0; i < PLATES.length; i++) {
      index = (index + 1) % PLATES.length;
      const p = currentPlate();
      if (!DONE.has(p) && !SKIPPED.has(p)) return p;
    }

    return currentPlate();
  }

  function panel() {
    let p = document.getElementById('fuelQrPanel');
    if (p) return p;

    p = document.createElement('div');
    p.id = 'fuelQrPanel';
    p.style.cssText = [
      'position:fixed',
      'top:15px',
      'right:15px',
      'z-index:2147483647',
      'background:#061b13',
      'color:#d9ffe8',
      'padding:14px',
      'font:12px monospace',
      'border:2px solid #00ff66',
      'border-radius:10px',
      'width:390px',
      'max-width:calc(100vw - 30px)',
      'white-space:pre-wrap',
      'box-shadow:0 0 20px rgba(0,255,102,.35)'
    ].join(';');

    const statusBox = document.createElement('div');
    statusBox.id = 'fuelQrStatus';
    statusBox.textContent = 'Инициализация...';
    p.appendChild(statusBox);

    const plateBox = document.createElement('div');
    plateBox.style.cssText = 'margin-top:10px;';
    plateBox.innerHTML = [
      '<div style="margin-bottom:4px;color:#9fffc0;">Номера, по одному в строке</div>',
      '<textarea id="fuelPlateList" rows="3" spellcheck="false" style="box-sizing:border-box;width:100%;resize:vertical;background:#001a0b;color:#d9ffe8;border:1px solid #00ff66;border-radius:4px;padding:6px;font:12px monospace;"></textarea>',
      '<label style="display:flex;align-items:center;gap:6px;margin-top:8px;color:#9fffc0;">',
      '<input id="fuelHardMode" type="checkbox">',
      '<span>Хард-режим при появлении топлива</span>',
      '</label>',
      '<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap;">',
      '<button id="fuelSavePlates" type="button">Сохранить номера</button>',
      '<button id="fuelResetPlates" type="button">Сбросить</button>',
      '</div>'
    ].join('');
    p.appendChild(plateBox);

    const actions = document.createElement('div');
    actions.style.cssText = 'margin-top:10px;display:flex;gap:4px;flex-wrap:wrap;';
    actions.innerHTML = [
      '<button id="fuelStart" type="button">Старт</button>',
      '<button id="fuelPause" type="button">Пауза</button>',
      '<button id="fuelStop" type="button">Стоп</button>',
      '<button id="fuelCheckQr" type="button">QR по номеру</button>'
    ].join('');
    p.appendChild(actions);

    document.body.appendChild(p);

    p.querySelectorAll('button').forEach(b => {
      b.style.cssText = 'background:#001a0b;color:#00ff66;border:1px solid #00ff66;padding:4px 8px;cursor:pointer;border-radius:4px;';
    });

    document.getElementById('fuelStart').onclick = start;
    document.getElementById('fuelPause').onclick = () => {
      paused = !paused;
      status(paused ? 'Пауза' : 'Мониторинг продолжается');
    };
    document.getElementById('fuelStop').onclick = stop;
    document.getElementById('fuelCheckQr').onclick = checkCurrentPlateQr;
    document.getElementById('fuelPlateList').value = PLATES.join('\n');
    document.getElementById('fuelHardMode').checked = hardModeEnabled;
    document.getElementById('fuelHardMode').onchange = (event) => {
      hardModeEnabled = !!event.target.checked;
      saveBool(HARD_MODE_KEY, hardModeEnabled);
      status(hardModeEnabled ? 'Хард-режим включен.' : 'Хард-режим выключен.');
    };
    document.getElementById('fuelSavePlates').onclick = () => {
      const raw = document.getElementById('fuelPlateList').value.split(/\s+/);
      if (savePlates(raw)) {
        DONE.clear();
        SKIPPED.clear();
        status('Список номеров сохранен.');
      } else {
        status('Не нашел ни одного корректного номера в списке.');
      }
    };
    document.getElementById('fuelResetPlates').onclick = () => {
      savePlates(DEFAULT_PLATES);
      document.getElementById('fuelPlateList').value = PLATES.join('\n');
      DONE.clear();
      SKIPPED.clear();
      status('Список номеров сброшен к значениям из скрипта.');
    };

    return p;
  }

  function status(text) {
    panel();

    const uiPlate = getUiPlate();
    const textValue = String(text || '');
    document.getElementById('fuelQrStatus').textContent =
      '=== FUEL QR reviewed ===\n' +
      `Готово: ${DONE.size}\n` +
      `Пропущено: ${SKIPPED.size}\n` +
      `Номеров в списке: ${PLATES.length}\n` +
      `Текущий номер: ${currentPlate()}\n` +
      `Источник номера: ${uiPlate ? 'поле приложения' : 'список PLATES'}\n` +
      `Сессия MAX: ${sessionLeft() > 0 ? sessionLeft() + ' сек.' : 'нет'}\n` +
      `Хард-режим: ${hardModeEnabled ? (isHardModeActive() ? 'активен' : 'готов') : 'выключен'}\n` +
      `Следующий опрос: ${pollMs} мс\n\n` +
      textValue;

    if (textValue !== lastStatusText || Date.now() - lastStatusLogAt > 30000) {
      lastStatusText = textValue;
      lastStatusLogAt = Date.now();
      log(textValue);
    }
  }

  async function api(path, body = null, timeoutMs = 12000) {
    const controller = new AbortController();
    const tid = setTimeout(() => controller.abort(), timeoutMs);

    try {
      debug('request:start', { path, method: body ? 'POST' : 'GET', body: safeJson(body), timeoutMs });
      const r = await fetch('/fuel/qr' + path, {
        method: body ? 'POST' : 'GET',
        credentials: 'include',
        cache: 'no-store',
        headers: { 'Content-Type': 'application/json' },
        body: body ? JSON.stringify(body) : null,
        signal: controller.signal
      });

      const text = await r.text();
      let json;
      try {
        json = JSON.parse(text);
      } catch {
        warn('request:invalid-json', { path, status: r.status, text: text.slice(0, 300) });
        const err = new ApiError(`Не JSON ${r.status}: ${text.slice(0, 120)}`, {}, r.status);
        err.path = path;
        throw err;
      }

      debug('request:response', {
        path,
        httpStatus: r.status,
        status: json.status,
        requestId: json.request_id,
        data: safeJson(json.data),
        message: json.message
      });

      if (!r.ok || json.status !== 'ok') {
        if (!(path === '/session/status' && r.status === 401)) {
          warn('request:api-error', {
            path,
            httpStatus: r.status,
            requestId: json.request_id,
            message: json.message,
            data: safeJson(json.data || json)
          });
        }
        const err = new ApiError(json.message || `HTTP ${r.status}`, json.data || json, r.status);
        err.path = path;
        throw err;
      }

      return json.data || {};
    } catch (e) {
      if (e.name === 'AbortError') {
        warn('request:timeout', { path, timeoutMs });
        throw new Error('Таймаут запроса');
      }
      if (!(path === '/session/status' && e.status === 401)) {
        warn('request:failed', { path, message: e.message || String(e), code: e.code, status: e.status });
      }
      throw e;
    } finally {
      clearTimeout(tid);
    }
  }

  function addUniqueFuel(list, item) {
    if (!item || list.some(x => String(x.id) === String(item.id))) return;
    list.push(item);
  }

  function chooseFuels(types) {
    debug('chooseFuels:available', safeJson(types));
    const ordered = [];

    for (const preferred of FUEL_PRIORITY) {
      const codes = preferred.codes || [preferred.code];
      const byCode = types.find(x => codes.includes(String(x.code || '').toLowerCase()));
      if (byCode) {
        addUniqueFuel(ordered, byCode);
      }
    }

    for (const preferred of FUEL_PRIORITY) {
      const byId = types.find(x => String(x.id) === String(preferred.id));
      if (byId) {
        addUniqueFuel(ordered, byId);
      }
    }

    for (const preferred of FUEL_PRIORITY) {
      const byTitle = types.find(x => preferred.title.test(String(x.title || '')));
      if (byTitle) {
        addUniqueFuel(ordered, byTitle);
      }
    }

    if (!ordered.length) {
      warn('chooseFuels:not-found', safeJson(types));
    } else {
      debug('chooseFuels:ordered', safeJson(ordered));
    }
    return ordered;
  }

  function chooseFuel(types) {
    const fuels = chooseFuels(types);
    if (!fuels.length) return null;
    const selected = fuels[fuelAttemptIndex % fuels.length];
    debug('chooseFuel:selected-round-robin', { index: fuelAttemptIndex, selected: safeJson(selected) });
    return selected;
  }

  function nextPollFromWait(wait) {
    const raw = wait && (wait.poll_after_ms || wait.pollAfterMs);
    const ms = parseInt(raw, 10);
    if (Number.isFinite(ms) && ms >= 500 && ms <= 60000) return ms;
    return DEFAULT_POLL_MS;
  }

  function isRegistrationPaused(data) {
    return !!(
      data
      && (
        data.block_reason === 'registration_not_open'
        || data.registration_state === 'not_open'
        || data.error_code === 'registration_not_open'
        || data.errorCode === 'registration_not_open'
      )
    );
  }

  function scheduleRegistrationWait(data, fallbackMessage) {
    const wait = data && data.wait ? data.wait : null;
    const waitMs = nextPollFromWait(wait);
    status((wait && wait.message) || fallbackMessage || 'QR registration is currently paused. Waiting...');
    schedule(waitMs);
  }

  function isHardModeActive() {
    return hardModeEnabled && Date.now() < hardModeUntil;
  }

  function hardDelay(ms) {
    return isHardModeActive() ? Math.min(ms, HARD_POLL_MS) : ms;
  }

  function requestNotificationPermission() {
    if (!hardModeEnabled || !('Notification' in window) || Notification.permission !== 'default') return;
    try {
      Notification.requestPermission().catch(function () {});
    } catch (e) {
      // Notification support differs across browsers/webviews.
    }
  }

  function hardBeep() {
    try {
      const AudioCtx = window.AudioContext || window.webkitAudioContext;
      if (!AudioCtx) return;
      const ctx = new AudioCtx();
      const osc = ctx.createOscillator();
      const gain = ctx.createGain();
      osc.frequency.value = 880;
      gain.gain.value = 0.08;
      osc.connect(gain);
      gain.connect(ctx.destination);
      osc.start();
      window.setTimeout(function () {
        osc.stop();
        ctx.close();
      }, 180);
    } catch (e) {
      // Audio can be blocked until a user gesture.
    }
  }

  function hardNotify(targetFuel) {
    if (!hardModeEnabled) return;
    if (Date.now() - lastHardAlertAt < HARD_WINDOW_MS) return;

    lastHardAlertAt = Date.now();
    hardBeep();
    if ('Notification' in window && Notification.permission === 'granted') {
      try {
        new Notification('Fuel QR: топливо появилось', {
          body: `${targetFuel.code || targetFuel.id}: ${targetFuel.title || ''}`,
          silent: false
        });
      } catch (e) {
        // Some embedded contexts block notifications.
      }
    }
  }

  function activateHardMode(targetFuel) {
    if (!hardModeEnabled) return;
    hardModeUntil = Date.now() + HARD_WINDOW_MS;
    hardNotify(targetFuel);
    debug('hard-mode:active', { until: hardModeUntil, targetFuel: safeJson(targetFuel) });
  }

  function schedule(ms = pollMs) {
    clearTimeout(timer);
    pollMs = Math.max(500, Math.min(ms, MAX_POLL_MS));
    debug('schedule', { ms: pollMs, stopped, paused, busy });
    if (!stopped) timer = setTimeout(mainLoop, pollMs);
  }

  function backoff() {
    return Math.min(Math.round(pollMs * BACKOFF), MAX_POLL_MS);
  }

  async function mainLoop() {
    debug('mainLoop:tick', {
      stopped,
      paused,
      busy,
      plate: currentPlate(),
      sessionLeft: sessionLeft(),
      done: Array.from(DONE),
      skipped: Array.from(SKIPPED)
    });

    if (stopped) return;

    if (paused) {
      schedule(DEFAULT_POLL_MS);
      return;
    }

    if (busy) {
      schedule(300);
      return;
    }

    if (!await ensureSession()) {
      status('Жду штатную MAX-сессию. Сначала поделитесь телефоном в приложении.');
      schedule(DEFAULT_POLL_MS);
      return;
    }

    if (!hasTargetsLeft()) {
      status('Номера обработаны. Работа завершена.');
      stopped = true;
      return;
    }

    if (DONE.has(currentPlate()) || SKIPPED.has(currentPlate())) {
      moveToNextTarget();
      schedule(100);
      return;
    }

    busy = true;

    try {
      const fuelData = await api('/fuel-types', null, 7000);
      const types = fuelData.fuel_types || [];
      debug('fuel-types:parsed', { count: types.length, wait: safeJson(fuelData.wait), registrationState: fuelData.registration_state });

      if (!types.length) {
        fuelAttemptIndex = 0;
        hardModeUntil = 0;
        const waitMs = nextPollFromWait(fuelData.wait);
        status('Топлива пока нет. Жду следующий серверный интервал.');
        schedule(waitMs);
        return;
      }

      pollMs = DEFAULT_POLL_MS;
      const targetFuel = chooseFuel(types);

      if (!targetFuel) {
        status('Топливо есть, но нужного типа нет.\nДоступно:\n' + types.map(x => `${x.id}: ${x.title}`).join('\n'));
        schedule(DEFAULT_POLL_MS);
        return;
      }

      activateHardMode(targetFuel);

      const p = currentPlate();
      debug('plate:selected', { plate: p, targetFuel: safeJson(targetFuel) });
      status(`Есть топливо: ${targetFuel.id}: ${targetFuel.title}\nПроверяю номер ${p}...`);

      const check = await api('/plate/check', {
        car_plate: p,
        plate_format_confirmed: plateFormatConfirmed(p)
      }, 10000);

      log('plate/check:', check);
      debug('plate/check:state', safeJson(check));

      if ((check.state === 'active' || check.state === 'active_own') && check.ticket) {
        processTicket(check.ticket, p, 'QR уже был активен');
        return;
      }

      if (check.state === 'active_other') {
        status(`${p}: активный QR есть у другой сессии. Пропускаю.`);
        SKIPPED.add(p);
        moveToNextTarget();
        schedule(100);
        return;
      }

      if (check.state === 'blocked') {
        if (isRegistrationPaused(check)) {
          scheduleRegistrationWait(check, check.message);
          return;
        }

        status(`${p} заблокирован.\n${check.message || check.next_create_at || ''}`);
        SKIPPED.add(p);
        moveToNextTarget();
        schedule(100);
        return;
      }

      if (check.state !== 'can_create') {
        status(`${p}: state=${check.state}. Жду дальше.`);
        schedule(hardDelay(DEFAULT_POLL_MS));
        return;
      }

      status(`Создаю QR для ${p}\nТопливо: ${targetFuel.title}`);

      const create = await api('/create', {
        car_plate: p,
        fuel_type_id: String(targetFuel.id),
        plate_format_confirmed: plateFormatConfirmed(p)
      }, 20000);

      log('create:', create);
      debug('create:result', safeJson(create));

      if (create.ticket && create.ticket.deeplink) {
        processTicket(create.ticket, p, 'QR создан');
        return;
      }

      if ((create.state === 'active' || create.state === 'active_own') && create.ticket) {
        processTicket(create.ticket, p, 'QR уже активен');
        return;
      }

      if (create.state === 'active_other') {
        status(`${p}: активный QR появился у другой сессии. Пропускаю номер.`);
        SKIPPED.add(p);
        moveToNextTarget();
        schedule(100);
        return;
      }

      status('create ответил без ticket. Продолжаю мониторинг.');
      schedule(hardDelay(DEFAULT_POLL_MS));
    } catch (e) {
      warn('mainLoop:error', { message: e.message || String(e), code: e.code, status: e.status, data: safeJson(e.data) });
      if (e.sessionExpired) {
        status('Сессия истекла. Нужно заново пройти MAX-доступ.');
        schedule(DEFAULT_POLL_MS);
      } else if (e.code === 'active_qr_exists') {
        status('По номеру уже есть активный QR. Проверьте вручную кнопкой "QR по номеру".');
        schedule(DEFAULT_POLL_MS);
      } else if (isRegistrationPaused(e.data)) {
        scheduleRegistrationWait(e.data, e.message);
      } else if (/fuel_unavailable|registration_not_open/.test(e.code || '')) {
        status(e.message || 'Топливо стало недоступно. Продолжаю мониторинг.');
        schedule(hardDelay(DEFAULT_POLL_MS));
      } else if (e.path === '/create') {
        fuelAttemptIndex += 1;
        status(`Создание QR не прошло: ${e.message || e}. Сразу пробую снова.`);
        schedule(CREATE_FAIL_COOLDOWN_MS);
      } else {
        status(`Ошибка: ${e.message || e}`);
        schedule(backoff());
      }
    } finally {
      busy = false;
    }
  }

  async function checkCurrentPlateQr() {
    try {
      if (!await ensureSession()) {
        status('Нет сессии. Сначала поделитесь телефоном в MAX.');
        return;
      }

      const p = currentPlate();
      status(`Проверяю QR только по номеру:\n${p}`);

      const check = await api('/plate/check', {
        car_plate: p,
        plate_format_confirmed: plateFormatConfirmed(p)
      }, 10000);

      log('manual plate/check:', check);

      if ((check.state === 'active' || check.state === 'active_own') && check.ticket) {
        processTicket(check.ticket, p, 'QR найден по номеру');
        return;
      }

      status(`QR по номеру не найден.\nstate=${check.state}`);
    } catch (e) {
      status(`Ошибка проверки номера:\n${e.message || e}`);
    }
  }

  function processTicket(ticket, plate, reason) {
    DONE.add(plate);

    log(`${reason}: ${plate}`, ticket);
    debug('ticket:processed', { plate, reason, ticket: safeJson(ticket) });
    status(`${reason} для ${plate}\n${ticket.deeplink}`);

    try {
      window.open(ticket.deeplink, '_blank');
    } catch (e) {
      log('window.open failed:', e);
    }

    drawQr(ticket, plate, DONE.size);

    if (getUiPlate()) {
      status('QR для номера из поля получен. Готово.');
      stopped = true;
      clearTimeout(timer);
      return;
    }

    if (hasTargetsLeft()) {
      moveToNextTarget();
      schedule(100);
    } else {
      status('Все доступные QR получены/найдены. Готово.');
      stopped = true;
      clearTimeout(timer);
    }
  }

  function drawQr(ticket, plate, order) {
    const old = document.getElementById('fuelQrImg_' + plate);
    if (old) old.remove();

    const wrap = document.createElement('div');
    wrap.id = 'fuelQrImg_' + plate;
    wrap.style.cssText = [
      'position:fixed',
      'z-index:2147483647',
      `top:${180 + (order - 1) * 270}px`,
      'right:15px',
      'width:230px',
      'background:white',
      'color:#000',
      'padding:8px',
      'border:4px solid #00ff66',
      'border-radius:10px',
      'font:12px sans-serif',
      'text-align:center',
      'box-shadow:0 0 15px rgba(0,255,102,.5)'
    ].join(';');

    const title = document.createElement('div');
    title.textContent = plate;
    title.style.cssText = 'font-weight:bold;margin-bottom:6px;';
    wrap.appendChild(title);

    const img = new Image();
    img.alt = `QR ${plate}`;
    img.style.cssText = 'width:220px;height:220px;display:block;';

    if (ticket.qr_png_base64) {
      img.src = 'data:image/png;base64,' + ticket.qr_png_base64;
    } else if (window.QRCode && typeof window.QRCode.toDataURL === 'function') {
      window.QRCode.toDataURL(ticket.deeplink, { width: 220 }).then(src => {
        img.src = src;
      }).catch(() => {
        title.textContent = `${plate} - QR render error`;
      });
    } else {
      title.textContent = `${plate} - QR renderer missing`;
    }

    wrap.appendChild(img);
    document.body.appendChild(wrap);
  }

  function start() {
    stopped = false;
    paused = false;
    busy = false;
    index = 0;
    pollMs = DEFAULT_POLL_MS;
    hardModeUntil = 0;
    requestNotificationPermission();

    const plateList = document.getElementById('fuelPlateList');
    if (plateList && savePlates(plateList.value.split(/\s+/))) {
      plateList.value = PLATES.join('\n');
    }

    DONE.clear();
    SKIPPED.clear();

    document.querySelectorAll('[id^="fuelQrImg_"]').forEach(el => el.remove());

    clearTimeout(timer);

    status('Мониторинг запущен.\nЕсли номер введен в поле, использую его. Иначе беру PLATES.');
    schedule(100);
  }

  function stop() {
    stopped = true;
    paused = false;
    busy = false;
    hardModeUntil = 0;
    clearTimeout(timer);
    status('Остановлено.');
  }

  panel();
  status('Готов. Reviewed-версия сверена с HTML/API.');
  setTimeout(start, 2000);
})();